Google Tag Manager now lets you add unit tests directly to your custom templates. This is useful, since it allows you to control the code stability of your templates, especially if you’ve decided to share those templates with the public.

I recently shared a general guide for how template tests work, but I wanted to expand the topic a little, and share with you two walkthroughs of custom template tests: one for a variable template and one for a tag template.

There’s a video if you prefer a more visual way to walk through these steps.

Tip 107: How to build tag and variable template tests

Let’s get started. We’ll kick things off with a variable template.

Variable template: String from array of objects

This walkthrough will utilize my String from array of objects template.

The template is very simple. It takes a GTM variable as an input, where the variable must return an array of objects (e.g. [{id: '123'},{id: '234'}]).

Then, the user specifies which property from the objects they want to concatenate the values of into a string (e.g. id), and finally they can specify a delimiter other than ,. The end result would be something like '123,234' if following the example from the previous paragraph.

Here is the template code in all its simplicity:

const callInWindow = require('callInWindow');
const getType = require('getType');

const inputArray = data.inputArray;
const keyToConcatenate = data.keyToConcatenate;
const delimiter = data.delimiter;

// If not an array, return undefined
if (getType(inputArray) !== 'array') {
  return;
}

return inputArray
  .map(obj => obj[keyToConcatenate])
  .filter(obj => obj)
  .join(delimiter);

When we deconstruct the variable code, there are four different things that can happen.

  1. The input is not an array, in which case the getType() check succeeds and the variable returns undefined.
  2. The array does not have any objects within, in which case the .map() and .filter() calls end up returning an empty array, which is then turned into an empty string by .join().
  3. The array has objects but none of them has the specified key, in which case the .map() and .filter() calls end up returning an empty array, which is then turned into an empty string by .join().
  4. The array has at least one object with the given key, in which case the variable returns a string where the key values are concatenated into a string using the delimiter.

In other words, we need four tests.

Test 1: Return undefined if not array

If the variable is not an array, the variable returns undefined. To test this, we create a mock data object, where we pass a non-array as the input.

This is what the test looks like:

// Call runCode to run the template's code.
const mockData = {
  inputArray: 'notAnArray',
  keyToConcatenate: 'name',
  delimiter: ','
};

const variableResult = runCode(mockData);

// Verify that the variable returns a result.
assertThat(variableResult).isUndefined();

The mockData object has the inputArray set to a string, which is not an array. This object is passed to runCode(), which executes the variable template as if the user had created the template with the non-array as the input.

Finally, the result of runCode() is asserted by passing the test if the return value of the variable is undefined, by using the isUndefined() assertion.

Test 2: Return empty string if no objects in array

In the next test, we need to create a scenario where the user does provide an array (so the getType() check doesn’t catch it), but this array will have no objects within. In that case, the variable would return an empty string.

// Call runCode to run the template's code.
const mockData = {
  inputArray: [1, 2, 3],
  keyToConcatenate: 'name',
  delimiter: ','
};

const expected = '';

const variableResult = runCode(mockData);

// Verify that the variable returns a result.
assertThat(variableResult).isEqualTo(expected);

Here we use the expected constant to make it easier to manage and read the code. Again, the mock data object is passed to runCode(), and the variable result is then asserted with the isEqualTo() check, this time passing the test if the variable result is exactly an empty string.

Test 3: Return empty string if objects in array do not have the key

To be fair, this could be bundled up with the previous test, because they test the same lines of code and the end result is the same.

// Call runCode to run the template's code.
const mockData = {
  inputArray: [{id: '123'},{category: 'shoes'}],
  keyToConcatenate: 'name',
  delimiter: ','
};

const expected = '';

const variableResult = runCode(mockData);

// Verify that the variable returns a result.
assertThat(variableResult).isEqualTo(expected);

As you can see from the mock object, we are looking for the key name in the objects of the array. Neither one of the objects in the inputArray has that key.

Once this mock object is passed to runCode(), the variable code is executed, and the variable ends up returning an empty string. This is asserted by checking the result against the expected value of ''.

Test 4: Return concatenated string with delimiter for valid object keys

The last test is the only positive check we need to do.

// Call runCode to run the template's code.
const mockData = {
  inputArray: [{name: 'firstName'},{name: 'secondName'}],
  keyToConcatenate: 'name',
  delimiter: ','
};

const expected = 'firstName,secondName';

const variableResult = runCode(mockData);

// Verify that the variable returns a result.
assertThat(variableResult).isEqualTo(expected);

This time, the mock data array has two objects, where both have the key name. Thus the expected constant is set to 'firstName,secondName', because that is what we expect the variable to return.

This is asserted with the isEqualTo() check again.

Tag template: User distributor

The second walkthrough will utilize my user distributor template. I wrote about how the template works here.

The tag template is quite a bit more complex than the variable template above. For one, we have branching decisions, and we also have collective catch-alls that apply to all branches.

The user distributor lets you choose whether to isolate a single group or multiple groups of users. If a random number matches the distribution you set for either option, the user can be assigned to a group, which is designated by writing a cookie in the browser.

In case the user is already in a group, the code is not executed, and in case the random number does not match a distribution range, the cookie is not written.

So, the scenarios we need to test for are these:

  1. If cookie exists, i.e. the user has already been assigned to a group, do nothing.
  2. If using a single distribution, set cookie to true if the user is assigned to the group.
  3. If using a single distribution, set cookie to false if the user is not assigned to the group.
  4. Make sure the single distribution cookie is written with the correct options.
  5. If using a multi distribution, set cookie to the first group when the randomizer is within the range.
  6. If using a multi distribution, set cookie to the second group when the randomizer is within the range.
  7. If using a multi distribution, do not set cookie if the randomizer is outside all group ranges.
  8. Make sure the multi distribution cookie is written with the correct options.

These eight tests should get 100% coverage for the tag template. And for reference, here is the full tag template code:

const setCookie = require('setCookie');
const readCookie = require('getCookieValues');
const generateRandom = require('generateRandom');
const makeInteger = require('makeInteger');
const log = require('logToConsole');

// Percentage accumulation for multi
let acc = 0;

// Randomizer
const rand = generateRandom(1, 100);

// User data
const cookieName = data.cookieName;
const cookieDomain = data.cookieDomain;
const cookieMaxAge = data.cookieExpires * 24 * 60 * 60;
const single = data.singlePercent;
const multi = data.multiPercent;

// Only run if cookie is not set
if (readCookie(cookieName).length === 0) {

  // If single distribution
  if (single) {
    setCookie(
      cookieName,
      (rand <= single ? 'true' : 'false'),
      {
        domain: cookieDomain,
        'max-age': cookieMaxAge
      }
    );
  }

  // If multi distribution
  if (multi) {
    multi.some(obj => {
      acc += makeInteger(obj.probability);
      if (rand <= acc) {
        setCookie(
          cookieName,
          obj.value,
          {
            domain: cookieDomain,
            'max-age': cookieMaxAge
          }
        );
        return true;
      }
    });
  }

}

data.gtmOnSuccess();

Let’s start with a test setup.

Setup: initialize the mock objects

This time around, we’ll be running multiple tests to check different aspects of the same mock data setups. Thus, instead of always initializing the mock objects for each test with (almost) identical values, we can instead create the mock objects in the Setup of the test, after which they will be in the scope of all test cases.

This is what the setup code looks like:

const mockSingleData = {
  cookieName: 'userDistributor',
  cookieDomain: 'auto',
  cookieExpires: 365,
  singlePercent: 50
};

const mockMultiData = {
  cookieName: 'userDistributor',
  cookieDomain: 'auto',
  cookieExpires: 365,
  multiPercent: [{
    value: 'A',
    probability: 33
  },{
    value: 'B',
    probability: 33
  }]
};

The single distribution mock sets the cookie userDistributor to 'true' if the randomizer generates a number between 1 and 50. Numbers between 51 and 100 set the cookie value to 'false'.

The multi distribution mock sets the cookie userDistributor to 'A' if the randomizer generates a number between 1 and 33, to 'B' for values between 34 and 66, and does not set the cookie for values between 67 and 100.

In both cases, the cookie is written with the 'auto' domain, and the expiration is set to 365 days.

The first scenario we’ll test is what happens if the cookie named userDistributor has already been set. In this case, the template shouldn’t really do anything - at least, it shouldn’t reset the cookie with a new value.

mock('getCookieValues', [true]);

// Call runCode to run the template's code.
runCode(mockSingleData);

assertApi('generateRandom').wasCalled();
assertApi('getCookieValues').wasCalled();
assertApi('makeInteger').wasNotCalled();
assertApi('setCookie').wasNotCalled();

// Verify that the tag finished successfully.
assertApi('gtmOnSuccess').wasCalled();

We use the mock() API here, which lets us fake the result of a custom template API. In this case, we’re instructing the template to return the value [true] whenever the getCookieValues API is invoked. The value within the array is inconsequential - all it has to do is return an array with a positive length for the template to believe that the cookie has been set.

Next, the single mock data object is passed to runCode().

After that, we simply check if the code has run by checking which custom template APIs have been called. We can see from the code that generateRandom() and getCookieValues() are called before any checks are done, but makeInteger() and setCookie() are only called if the cookie has not been set yet.

Thus, we can use the wasCalled() and wasNotCalled() APIs to check which branch of the template code was executed.

Finally, we ensure that gtmOnSuccess() was called, since otherwise the tag would stall.

The next case we’ll test is that the cookie is set to value 'true' when the randomizer generates a value that is within the range of the single distribution group probability.

mock('getCookieValues', []);
mock('generateRandom', 50);
mock('setCookie', (name, value, options) => {
  if (value !== 'true') {
    fail('setCookie not called with "true"');
  }
});

// Call runCode to run the template's code.
runCode(mockSingleData);

assertApi('generateRandom').wasCalled();
assertApi('getCookieValues').wasCalled();
assertApi('makeInteger').wasNotCalled();
assertApi('setCookie').wasCalled();

// Verify that the tag finished successfully.
assertApi('gtmOnSuccess').wasCalled();

Check out the mock() calls. The first one is similar to the one in the previous test, except this time we have it return an empty array to signify the cookie has not been set.

Then, we mock the generateRandom() API to have it return the value 50, just to test the upper bound of the distribution range of 1...50 as set in the mock object within the Setup of the test.

The third mock() is interesting! We are mocking setCookie() because a) we don’t want it to actually set a cookie, and b) we can use this patch to check what setCookie() was called with. In case setCookie() was not called with the value 'true', we fail the test.

You can use the mock() together with fail() to check what APIs have been called with - it’s a nice workaround while we wait for wasCalledWith() for the assertApi() API.

Then, we run the code with the mock data object again.

The list of APIs we expect to be called is almost the same as in the previous test, with the exception that now we expect setCookie() to be called, since the userDistributor cookie does not exist in this scenario.

This test succeeds if the setCookie() API is called with the expected value of 'true' for the cookie.

This test is basically a mirror of the previous one, except we set a different return value for the generateRandom() mock to ensure the cookie is set with the value 'false'. We’re using the lower bound of the range (51) just to test the extremes of the algorithm.

mock('getCookieValues', []);
mock('generateRandom', 51);
mock('setCookie', (name, value, options) => {
  if (value !== 'false') {
    fail('setCookie not called with "false"');
  }
});

// Call runCode to run the template's code.
runCode(mockSingleData);

assertApi('generateRandom').wasCalled();
assertApi('getCookieValues').wasCalled();
assertApi('makeInteger').wasNotCalled();
assertApi('setCookie').wasCalled();

// Verify that the tag finished successfully.
assertApi('gtmOnSuccess').wasCalled();

Other than those two changes, the test code is identical to the previous one.

This test is a simple check to make sure that the cookie settings (name, value, domain, and expiration) correspond with those set in the template configuration. It’s a solid test to write, because often the little configuration mistakes that don’t really break the code are the ones that slip through the cracks when creating incremental updates.

mock('getCookieValues', []);
mock('generateRandom', 99);
mock('setCookie', (name, value, options) => {
  if (name !== mockSingleData.cookieName) fail('setCookie called with incorrect cookie name');
  if (value !== 'false') fail('setCookie not called with "false"');
  if (options.domain !== mockSingleData.cookieDomain) fail('setCookie called with incorrect cookie domain');
  if (options['max-age'] !== mockSingleData.cookieExpires * 24 * 60 * 60) fail('setCookie called with incorrect max-age');
});

// Call runCode to run the template's code.
runCode(mockSingleData);

assertApi('generateRandom').wasCalled();
assertApi('getCookieValues').wasCalled();
assertApi('makeInteger').wasNotCalled();
assertApi('setCookie').wasCalled();

// Verify that the tag finished successfully.
assertApi('gtmOnSuccess').wasCalled();

The magic happens in the setCookie() mock. Basically, we fail the test if the setCookie() API is called with values that do not correspond to those we set in the mock data object.

When we start testing the multi distribution options, we are basically checking if the randomized number falls into the distributions established in the tag settings.

We’ll first test the simplest scenario: the randomizer picks the first group the user defined. We use this by setting it to the upper bound of the first group.

mock('getCookieValues', []);
mock('generateRandom', 33);
mock('setCookie', (name, value, options) => {
  if (value !== 'A') {
    fail('setCookie not called with "A"');
  }
});

// Call runCode to run the template's code.
runCode(mockMultiData);

assertApi('generateRandom').wasCalled();
assertApi('getCookieValues').wasCalled();
assertApi('makeInteger').wasCalled();
assertApi('setCookie').wasCalled();

// Verify that the tag finished successfully.
assertApi('gtmOnSuccess').wasCalled();

The setup is very similar to the previous single distribution tests, with the difference that the cookie value is now established in the mock data object, and we expect the makeInteger() API to be called for the template test to succeed.

This test is practically identical to the previous one, with the exception that we set the generateRandom() API to return a value that sits within the lower bound of the second group of the mock object, and we modify the setCookie() API to check for the corresponding value ('B') instead.

mock('getCookieValues', []);
mock('generateRandom', 34);
mock('setCookie', (name, value, options) => {
  if (value !== 'B') {
    fail('setCookie not called with "B"');
  }
});

// Call runCode to run the template's code.
runCode(mockMultiData);

assertApi('generateRandom').wasCalled();
assertApi('getCookieValues').wasCalled();
assertApi('makeInteger').wasCalled();
assertApi('setCookie').wasCalled();

// Verify that the tag finished successfully.
assertApi('gtmOnSuccess').wasCalled();

In case the randomizer does not fall into any of the probability ranges established in the multi distribution settings, the cookie should not be set.

The easiest way to check this is by making sure the setCookie() API was not called. You can do this with assertApi('setCookie').wasNotCalled(), but you can also mock the setCookie() API to fail if called. I’ve opted to do both just for giggles.

mock('getCookieValues', []);
mock('generateRandom', 67);
mock('setCookie', (name, value, options) => {
  fail('setCookie should not have been called');
});

// Call runCode to run the template's code.
runCode(mockMultiData);

assertApi('generateRandom').wasCalled();
assertApi('getCookieValues').wasCalled();
assertApi('makeInteger').wasCalled();
assertApi('setCookie').wasNotCalled();

// Verify that the tag finished successfully.
assertApi('gtmOnSuccess').wasCalled();

Here, we generate a random number that is above 66 to make sure the number doesn’t belong to any group. Then, we mock setCookie() to fail the test if ever called (regardless of what values it was called with), and we also use assertApi() to pass the test only if the API was not called.

This is similar to Test 4, since we are just checking that the cookie is set with options that correspond with those configured in the template settings (or in the mock data object as in this case). The test will fail if there are any differences.

mock('getCookieValues', []);
mock('generateRandom', 20);
mock('setCookie', (name, value, options) => {
  if (name !== mockMultiData.cookieName) fail('setCookie called with incorrect cookie name');
  if (value !== mockMultiData.multiPercent[0].value) fail('setCookie called with incorrect cookie value');
  if (options.domain !== mockMultiData.cookieDomain) fail('setCookie called with incorrect cookie domain');
  if (options['max-age'] !== mockMultiData.cookieExpires * 24 * 60 * 60) fail('setCookie called with incorrect max-age');
});

// Call runCode to run the template's code.
runCode(mockMultiData);

assertApi('generateRandom').wasCalled();
assertApi('getCookieValues').wasCalled();
assertApi('makeInteger').wasCalled();
assertApi('setCookie').wasCalled();

// Verify that the tag finished successfully.
assertApi('gtmOnSuccess').wasCalled();

Summary

In this article, I showed you two walkthroughs for how to write tests for your variable and tag templates.

A good way to get started is to figure out all the branches of your code, and then write tests that check for all the permutations.

To reduce the number of tests you need, make sure you utilize the field configurations (particularly the validation rules) efficiently. By blocking invalid input before the tag or variable can even be saved you’re reducing the number of validation checks you need to write (and test for) in the code.

Hopefully we’ll get some test coverage details into the UI, so that you can plan your tests to cover all possible branches and features of the template code.

Finally, remember to update the tests when you update the template itself. Writing and updating tests should become part and parcel of your quality assurance process when writing templates. You owe it to the users who end up installing your template into their Google Tag Manager containers!