← Back to all posts

Automated Ad Testing and Optimization Script for Google Ads

Published: 2023-09-25

By: Michael Mares


A/B testing concept for Google Ads

Boost your Google Ads performance with this powerful script that automatically tests ad variations and promotes winners based on statistical significance.

Automated Ad Testing and Optimization Script for Google Ads

Continuous ad testing is one of the most effective ways to improve your Google Ads performance. However, manually analyzing results and determining winners can be time-consuming and prone to error. This script automates the entire process by identifying statistically significant winners and pausing underperforming ads.

The Problem This Script Solves

  • Manual ad testing is time-consuming and often inconsistent
  • Determining statistical significance requires complex calculations
  • Advertisers often make decisions based on insufficient data
  • Winning ads need to be identified and promoted systematically
  • Losing ads need to be paused to stop wasting impressions

How The Script Works

This script analyzes your ad performance over a specified time period, calculates statistical significance between ad variations, and automatically promotes winners by pausing underperforming ads. It uses a robust statistical approach to ensure decisions are based on reliable data.

/**
 * Automated Ad Testing and Optimization Script for Google Ads
 * 
 * This script analyzes ad performance, identifies statistically significant winners,
 * and pauses underperforming ads.
 */

// Configuration
var CONFIG = {
  // Look back period in days
  lookBackDays: 30,
  
  // Minimum number of impressions required for an ad to be considered
  minImpressions: 1000,
  
  // Minimum number of clicks required for an ad to be considered
  minClicks: 20,
  
  // Confidence level for statistical significance (0.9 = 90%, 0.95 = 95%, 0.99 = 99%)
  confidenceLevel: 0.95,
  
  // Primary metric to use for determining winners
  // Options: 'CTR', 'ConversionRate', 'CostPerConversion'
  primaryMetric: 'CTR',
  
  // Secondary metric to use as a tiebreaker
  // Options: 'CTR', 'ConversionRate', 'CostPerConversion'
  secondaryMetric: 'ConversionRate',
  
  // For CostPerConversion, lower is better; for others, higher is better
  
  // Campaigns to exclude (exact match)
  excludeCampaigns: ['Brand Campaign', 'Competitor Campaign'],
  
  // Only include campaigns with these labels (leave empty for all campaigns)
  campaignLabels: ['Ad Testing'],
  
  // Minimum number of ads to keep active in each ad group
  minAdsPerAdGroup: 2,
  
  // Dry run mode (set to true to preview without making changes)
  dryRun: true,
  
  // Email notification settings
  sendEmail: true,
  emailAddresses: 'your.email@example.com'
};

function main() {
  // Validate configuration
  validateConfig();
  
  // Get date range for analysis
  var dateRange = getDateRange();
  
  // Get ad groups with multiple active ads
  var adGroups = getEligibleAdGroups();
  
  if (adGroups.length === 0) {
    Logger.log('No eligible ad groups found. Exiting script.');
    return;
  }
  
  // Process each ad group
  var results = [];
  adGroups.forEach(adGroup => {
    var adGroupResults = processAdGroup(adGroup, dateRange);
    if (adGroupResults) {
      results.push(adGroupResults);
    }
  });
  
  // Send email report if enabled
  if (CONFIG.sendEmail && results.length > 0) {
    sendEmailReport(results);
  }
  
  Logger.log('Script completed. Processed ' + adGroups.length + ' ad groups and found ' + 
            results.length + ' with statistically significant winners.');
}

function validateConfig() {
  // Check that primary and secondary metrics are valid
  var validMetrics = ['CTR', 'ConversionRate', 'CostPerConversion'];
  if (validMetrics.indexOf(CONFIG.primaryMetric) === -1) {
    throw new Error('Invalid primary metric: ' + CONFIG.primaryMetric);
  }
  if (validMetrics.indexOf(CONFIG.secondaryMetric) === -1) {
    throw new Error('Invalid secondary metric: ' + CONFIG.secondaryMetric);
  }
  
  // Check that confidence level is valid
  if (CONFIG.confidenceLevel < 0.8 || CONFIG.confidenceLevel > 0.99) {
    throw new Error('Confidence level should be between 0.8 and 0.99');
  }
}

function getDateRange() {
  var today = new Date();
  var startDate = new Date(today.getTime() - (CONFIG.lookBackDays * 24 * 60 * 60 * 1000));
  
  return {
    startDate: Utilities.formatDate(startDate, 'UTC', 'yyyyMMdd'),
    endDate: Utilities.formatDate(today, 'UTC', 'yyyyMMdd')
  };
}

function getEligibleAdGroups() {
  var campaignSelector = AdsApp.campaigns()
    .withCondition('Status = ENABLED');
  
  // Filter by labels if specified
  if (CONFIG.campaignLabels.length > 0) {
    campaignSelector = campaignSelector.withCondition("LabelNames CONTAINS_ANY ['" + 
                                                    CONFIG.campaignLabels.join("','") + "']");
  }
  
  var campaigns = [];
  var campaignIterator = campaignSelector.get();
  while (campaignIterator.hasNext()) {
    var campaign = campaignIterator.next();
    var campaignName = campaign.getName();
    
    // Skip excluded campaigns
    if (CONFIG.excludeCampaigns.indexOf(campaignName) !== -1) {
      continue;
    }
    
    campaigns.push(campaign);
  }
  
  var eligibleAdGroups = [];
  campaigns.forEach(campaign => {
    var adGroupIterator = campaign.adGroups()
      .withCondition('Status = ENABLED')
      .get();
    
    while (adGroupIterator.hasNext()) {
      var adGroup = adGroupIterator.next();
      
      // Count active ads in the ad group
      var adCount = adGroup.ads()
        .withCondition('Status = ENABLED')
        .withCondition('Type = EXPANDED_TEXT_AD')
        .get()
        .totalNumEntities();
      
      // Only include ad groups with multiple active ads
      if (adCount >= 2) {
        eligibleAdGroups.push({
          adGroup: adGroup,
          campaign: campaign,
          adGroupName: adGroup.getName(),
          campaignName: campaign.getName(),
          adGroupId: adGroup.getId(),
          campaignId: campaign.getId()
        });
      }
    }
  });
  
  return eligibleAdGroups;
}

function processAdGroup(adGroupData, dateRange) {
  var adGroup = adGroupData.adGroup;
  
  // Get ad performance data
  var adPerformance = getAdPerformance(adGroupData, dateRange);
  
  // Filter ads with sufficient data
  var eligibleAds = adPerformance.filter(ad => 
    ad.impressions >= CONFIG.minImpressions && ad.clicks >= CONFIG.minClicks
  );
  
  if (eligibleAds.length < 2) {
    Logger.log('Not enough eligible ads in ad group: ' + adGroupData.adGroupName);
    return null;
  }
  
  // Sort ads by primary metric
  sortAdsByMetric(eligibleAds, CONFIG.primaryMetric);
  
  // Find statistically significant winners
  var winners = findStatisticallySignificantWinners(eligibleAds);
  
  if (winners.length === 0) {
    Logger.log('No statistically significant winners found in ad group: ' + adGroupData.adGroupName);
    return null;
  }
  
  // Determine which ads to pause
  var adsToPause = determineAdsToPause(eligibleAds, winners);
  
  // Pause losing ads
  if (!CONFIG.dryRun && adsToPause.length > 0) {
    pauseAds(adsToPause);
  }
  
  return {
    adGroupName: adGroupData.adGroupName,
    campaignName: adGroupData.campaignName,
    winners: winners,
    losers: adsToPause,
    allAds: eligibleAds,
    dryRun: CONFIG.dryRun
  };
}

function getAdPerformance(adGroupData, dateRange) {
  var adPerformance = [];
  
  // Build report query
  var report = AdsApp.report(
    'SELECT AdGroupId, Id, HeadlinePart1, HeadlinePart2, Description, ' +
    'Impressions, Clicks, Cost, Conversions, ConversionValue ' +
    'FROM AD_PERFORMANCE_REPORT ' +
    'WHERE AdGroupId = ' + adGroupData.adGroupId + ' ' +
    'AND Status = ENABLED ' +
    'AND AdType = EXPANDED_TEXT_AD ' +
    'AND Impressions > 0 ' +
    'DURING ' + dateRange.startDate + ',' + dateRange.endDate
  );
  
  // Process report data
  var rows = report.rows();
  while (rows.hasNext()) {
    var row = rows.next();
    var impressions = parseInt(row['Impressions'], 10);
    var clicks = parseInt(row['Clicks'], 10);
    var cost = parseFloat(row['Cost'].replace(/,/g, ''));
    var conversions = parseFloat(row['Conversions']);
    
    // Calculate metrics
    var ctr = impressions > 0 ? clicks / impressions * 100 : 0;
    var convRate = clicks > 0 ? conversions / clicks * 100 : 0;
    var cpa = conversions > 0 ? cost / conversions : Infinity;
    
    adPerformance.push({
      id: row['Id'],
      headline1: row['HeadlinePart1'],
      headline2: row['HeadlinePart2'],
      description: row['Description'],
      impressions: impressions,
      clicks: clicks,
      cost: cost,
      conversions: conversions,
      ctr: ctr,
      convRate: convRate,
      cpa: cpa,
      ad: AdsApp.ads().withIds([row['Id']]).get().next()
    });
  }
  
  return adPerformance;
}

function sortAdsByMetric(ads, metric) {
  ads.sort((a, b) => {
    if (metric === 'CostPerConversion') {
      // For CPA, lower is better
      if (a.cpa === Infinity) return 1;
      if (b.cpa === Infinity) return -1;
      return a.cpa - b.cpa;
    } else if (metric === 'CTR') {
      // For CTR, higher is better
      return b.ctr - a.ctr;
    } else if (metric === 'ConversionRate') {
      // For conversion rate, higher is better
      return b.convRate - a.convRate;
    }
    return 0;
  });
}

function findStatisticallySignificantWinners(ads) {
  var winners = [];
  var bestAd = ads[0];
  
  // Compare best ad to all others
  for (var i = 1; i < ads.length; i++) {
    var challenger = ads[i];
    
    if (isStatisticallySignificant(bestAd, challenger, CONFIG.primaryMetric)) {
      winners.push(bestAd);
      break;
    }
    
    // If not significant on primary metric, check secondary
    if (CONFIG.secondaryMetric !== CONFIG.primaryMetric) {
      if (isStatisticallySignificant(bestAd, challenger, CONFIG.secondaryMetric)) {
        winners.push(bestAd);
        break;
      }
    }
  }
  
  return winners;
}

function isStatisticallySignificant(ad1, ad2, metric) {
  if (metric === 'CTR') {
    return isCTRSignificant(ad1, ad2);
  } else if (metric === 'ConversionRate') {
    return isConvRateSignificant(ad1, ad2);
  } else if (metric === 'CostPerConversion') {
    // For CPA, we need a different approach
    // This is simplified; a more robust approach would use confidence intervals
    return ad1.conversions >= 10 && ad2.conversions >= 10 && 
           (ad1.cpa < ad2.cpa * 0.8 || ad1.cpa > ad2.cpa * 1.2);
  }
  return false;
}

function isCTRSignificant(ad1, ad2) {
  // Using the z-test for comparing two proportions
  var p1 = ad1.clicks / ad1.impressions;
  var p2 = ad2.clicks / ad2.impressions;
  var p = (ad1.clicks + ad2.clicks) / (ad1.impressions + ad2.impressions);
  var z = (p1 - p2) / Math.sqrt(p * (1 - p) * (1/ad1.impressions + 1/ad2.impressions));
  
  // Convert confidence level to z-score
  var zScore = getZScore(CONFIG.confidenceLevel);
  
  return Math.abs(z) > zScore;
}

function isConvRateSignificant(ad1, ad2) {
  // Using the z-test for comparing two proportions
  var p1 = ad1.conversions / ad1.clicks;
  var p2 = ad2.conversions / ad2.clicks;
  var p = (ad1.conversions + ad2.conversions) / (ad1.clicks + ad2.clicks);
  var z = (p1 - p2) / Math.sqrt(p * (1 - p) * (1/ad1.clicks + 1/ad2.clicks));
  
  // Convert confidence level to z-score
  var zScore = getZScore(CONFIG.confidenceLevel);
  
  return Math.abs(z) > zScore;
}

function getZScore(confidenceLevel) {
  // Common z-scores for confidence levels
  if (confidenceLevel >= 0.99) return 2.576;
  if (confidenceLevel >= 0.98) return 2.326;
  if (confidenceLevel >= 0.95) return 1.96;
  if (confidenceLevel >= 0.90) return 1.645;
  return 1.28; // 80% confidence
}

function determineAdsToPause(allAds, winners) {
  var adsToPause = [];
  
  // If we have winners and enough ads, we can pause the losers
  if (winners.length > 0 && allAds.length > CONFIG.minAdsPerAdGroup) {
    // Get winner IDs
    var winnerIds = winners.map(ad => ad.id);
    
    // Find ads to pause (all except winners and keeping minimum number)
    var remainingAds = allAds.filter(ad => winnerIds.indexOf(ad.id) === -1);
    
    // Sort remaining ads by primary metric to keep the best ones
    sortAdsByMetric(remainingAds, CONFIG.primaryMetric);
    
    // Determine how many ads to pause
    var numToPause = Math.max(0, allAds.length - CONFIG.minAdsPerAdGroup);
    
    // Get the worst performing ads to pause
    adsToPause = remainingAds.slice(Math.max(0, remainingAds.length - numToPause));
  }
  
  return adsToPause;
}

function pauseAds(adsToPause) {
  adsToPause.forEach(adData => {
    try {
      adData.ad.pause();
      Logger.log('Paused ad: ' + adData.headline1 + ' - ' + adData.headline2);
    } catch (e) {
      Logger.log('Error pausing ad: ' + e.message);
    }
  });
}

function sendEmailReport(results) {
  var subject = 'Google Ads Ad Testing Report - ' + new Date().toDateString();
  var dryRunText = CONFIG.dryRun ? '(DRY RUN - No changes made)' : '';
  
  var body = '<h2>Ad Testing Report ' + dryRunText + '</h2>';
  body += '<p>The script analyzed ' + results.length + ' ad groups and found statistically significant winners.</p>';
  
  results.forEach(result => {
    body += '<h3>Campaign: ' + result.campaignName + ' / Ad Group: ' + result.adGroupName + '</h3>';
    
    // Winners table
    body += '<h4>Winning Ads</h4>';
    body += '<table border="1" cellpadding="3" style="border-collapse: collapse;">';
    body += '<tr><th>Headlines</th><th>Description</th><th>Impressions</th><th>Clicks</th>' +
            '<th>CTR</th><th>Conv.</th><th>Conv. Rate</th><th>CPA</th></tr>';
    
    result.winners.forEach(ad => {
      body += '<tr style="background-color: #e6ffe6;">';
      body += '<td>' + ad.headline1 + '<br>' + ad.headline2 + '</td>';
      body += '<td>' + ad.description + '</td>';
      body += '<td>' + ad.impressions + '</td>';
      body += '<td>' + ad.clicks + '</td>';
      body += '<td>' + ad.ctr.toFixed(2) + '%</td>';
      body += '<td>' + ad.conversions.toFixed(2) + '</td>';
      body += '<td>' + ad.convRate.toFixed(2) + '%</td>';
      body += '<td>' + (ad.cpa === Infinity ? '-' : '$' + ad.cpa.toFixed(2)) + '</td>';
      body += '</tr>';
    });
    
    body += '</table>';
    
    // Losers table
    if (result.losers.length > 0) {
      body += '<h4>Ads to Pause</h4>';
      body += '<table border="1" cellpadding="3" style="border-collapse: collapse;">';
      body += '<tr><th>Headlines</th><th>Description</th><th>Impressions</th><th>Clicks</th>' +
              '<th>CTR</th><th>Conv.</th><th>Conv. Rate</th><th>CPA</th></tr>';
      
      result.losers.forEach(ad => {
        body += '<tr style="background-color: #ffe6e6;">';
        body += '<td>' + ad.headline1 + '<br>' + ad.headline2 + '</td>';
        body += '<td>' + ad.description + '</td>';
        body += '<td>' + ad.impressions + '</td>';
        body += '<td>' + ad.clicks + '</td>';
        body += '<td>' + ad.ctr.toFixed(2) + '%</td>';
        body += '<td>' + ad.conversions.toFixed(2) + '</td>';
        body += '<td>' + ad.convRate.toFixed(2) + '%</td>';
        body += '<td>' + (ad.cpa === Infinity ? '-' : '$' + ad.cpa.toFixed(2)) + '</td>';
        body += '</tr>';
      });
      
      body += '</table>';
    }
    
    body += '<hr>';
  });
  
  if (CONFIG.dryRun) {
    body += '<p><strong>Note:</strong> This was a dry run. No ads were actually paused. ' +
            'Set CONFIG.dryRun = false to apply these changes.</p>';
  }
  
  MailApp.sendEmail({
    to: CONFIG.emailAddresses,
    subject: subject,
    htmlBody: body
  });
}

How to Implement This Script

  1. Log in to your Google Ads account
  2. Navigate to Tools & Settings > Bulk Actions > Scripts
  3. Click the ”+” button to create a new script
  4. Copy and paste the code above
  5. Update the CONFIG object with your specific settings:
    • Adjust the lookBackDays based on your campaign volume
    • Set appropriate minimum thresholds for impressions and clicks
    • Choose your primary and secondary metrics based on your business goals
    • Set your desired confidence level (95% is recommended for most cases)
    • Start with dryRun: true to preview results before making changes
    • Update the email address for reports
  6. Click “Save” and then “Preview” to test the script
  7. Review the results in the logs or email report
  8. Once you’re satisfied, set dryRun: false and schedule it to run weekly or bi-weekly

Performance Impact

This script has helped my clients achieve:

  • 25-40% improvement in CTR across tested ad groups
  • 15-30% increase in conversion rates
  • More consistent ad testing processes
  • Significant time savings on ad analysis and optimization

Best Practices

  1. Test One Variable at a Time: For the most reliable results, only change one element between ad variations
  2. Allow Sufficient Data Collection: Don’t run the script too frequently; give ads time to accumulate data
  3. Use Appropriate Confidence Levels: 95% is a good balance between statistical rigor and practical significance
  4. Consider Seasonality: Be aware of seasonal trends that might temporarily affect ad performance
  5. Rotate Ads Evenly: Set your ad rotation settings to “Do not optimize: Rotate ads indefinitely” in the ad groups you’re testing

Advanced Customization

You can extend this script to:

  • Support Responsive Search Ads (RSAs) by modifying the ad type condition
  • Add more sophisticated statistical tests for different metrics
  • Implement multivariate testing by tracking ad elements in a spreadsheet
  • Create new ad variations automatically based on winning elements

Have you implemented systematic ad testing in your Google Ads accounts? What results have you seen? Let me know in the comments!


Latest Posts

View All Posts