DEV Community

Dan P
Dan P

Posted on • Edited on

Unit Testing Complex Jsonnet Objects and Arrays

A Problem

As part of our deployment strategy we generate kubernetes manifest json files from templates. For this we use Jsonnet. We need confidence that the "rendered" json files are correct depending on configuration that has been given to it. Therefore, we unit test them. We use the really helpful JsonnetUnit to achieve this. It's a really handy library that behaves like a unit testing runner, but is written entirely in jsonnet.

One issue we had was that JsonnetUnit compares objects in their entirety. For example, to assert the state of pod environment variables in a kubernetes manifest, you need to compare the entire list of env vars.

testEnvVars: {
    actual: deploy.spec.template.spec.containers[0].env,
    expect: [
            { name: 'ENVVAR1', value: 'VALUE1' },
            { name: 'FEATURE_A', value: 'true' },
        ]
  },
Enter fullscreen mode Exit fullscreen mode

Now this is easy enough if you have a small list of env vars, but if the intention of your unit test is to check if some extra values are provided when a feature is turned on, you dont want to have to compare the entire list of environment variable for the sake of a checking a couple of new ones exist.

A Solution

The following jsonnet code behaves like an "array contains" function. It returns a boolean response depending if the array (haystack) contains the required objects (needles).

As you might expect, when working in Json as a testing language (๐Ÿคจ), it's not a simple as calling a lookup function on the array. Kubernetes doesn't make life easier either, as env vars are stored as an array of objects rather than the more traditional form of a map of key, values.

Because of this, the function needs to know the structure of the object it is looking for in advance. Which is fine until you want to do a similar test for a slightly different shaped object (SecretEnvVars et al.), which requires the code to be copy pasted and amended.

{
  // containsEnvVars checks that all needles (k8s env variable "name" and "value" objs) exist in the haystack (pod env block) object
  // i.e. asserts array contains n objects
  containsEnvVars(haystack, needles):: (
    // create an array, 1 element per needle
    local results = [
      // check the needle is valid (it contains the two required fields)
      if (std.objectHas(needle, 'name') && std.objectHas(needle, 'value')) then
        // needle is valid

        // create a sub array to contain result of every haystack item for the current needle
        local h = [
          // if the haystack name and value both match the needle, add 'true' to the sub array
          if (needle.name == haystackItem.name) && needle.value == haystackItem.value then true
          for haystackItem in haystack
        ];
        // strip out the nulls, leaving only a true for the items where the needle and haystack match
        local prunedResults = std.prune(h);

        if std.length(prunedResults) == 0 then
           error "needle: " + needle + " was not found in the haystack"
        else
          true
      else
        // needle is not value, raise an error
        error 'needle ' + needle + ': doesnt contain the fields "name" and "value"'
      // body of the loop is the above logic
      for needle in needles
    ];

    // remove the empty sub arrays, these are where a needle was not matched to a haystackItem
    // therefore indicate a needle wasn't found
    local prunedResults = std.prune(results);

    //the number of results (bool true) remaining should equal the number of needles, indicating they were all found
    if std.count(prunedResults, true) < std.length(needles) then
      false
    else
      true
  )
}
Enter fullscreen mode Exit fullscreen mode

An example usage. Here we simply want to test that an additional environment variable is present when a config flag is turned on.

local test = import 'lib/jsonnetunit/jsonnetunit/test.libsonnet';
local utils = import 'lib/testhelpers.libsonnet';
local deploy = import 'templates/deployment-app.libsonnet';

local results = test.suite({
// Make sure the envVars contain ADDITIONAL_ENV: ADDITIONAL_VALUE without having to compare the entire map
testAdditionalEnvVars: {
  // In a real case, this vars block would be from the unit under test, in our case a rendered k8s manifest.
  local exampleVars: [
    { "name": "DB_HOST",        "value": "thedb.domain.com"          },
    { "name": "DB_PORT",        "value": 3306                        },
    { "name": "REDIS_HOST",     "value": "redis.anotherdomain.local" },
    { "name": "REDIS_PORT",     "value": 6379                        },
    { "name": "ADDITIONAL_ENV", "value": "ADDITIONAL_VALUE"          }
]

  actual: utils.containsEnvVars(exampleVars, [{ name: 'ADDITIONAL_ENV', value: 'ADDITIONAL_VALUE' }]),
  expect: true,
},
Enter fullscreen mode Exit fullscreen mode

And of course, this test helper also needed testing itself.

local test = import 'lib/jsonnetunit/jsonnetunit/test.libsonnet';
local utils = import 'lib/testhelpers.libsonnet';

local mockEnvs = [{ name: 'n1', value: 'v1' }, { name: 'n2', value: 'v2' }, { name: 'n3', value: 'v3' }];

test.suite({
  testContainsSingleEnvVar: {
    expect: utils.containsEnvVars(mockEnvs, [{ name: 'n1', value: 'v1' }]),
    actual: true,
  },

  testContainsTwoEnvVars: {
    actual: utils.containsEnvVars(mockEnvs, [{ name: 'n2', value: 'v2' }, { name: 'n3', value: 'v3' }]),
    expect: true,
  },

  testDoesntContainsTwoEnvVars: {
    actual: utils.containsEnvVars(mockEnvs, [{ name: 'n5', value: 'v5' }, { name: 'n6', value: 'v6' }]),
    expect: false,
  },

  ... etc
})

Enter fullscreen mode Exit fullscreen mode

A Bonus Feature

If we WERE only looking for key:value pairs in a map, the following would suffice

// containsObject will assert that the k:v's provided exist in the haystack object
// i.e assert map contains n entries
containsObject(haystack, needles):: (

// create a local array containing the element 'true' if the key and value of each needle exists in haystack
local results = [
    if std.objectHas(haystack, need) && (needles[need] == haystack[need]) then
      true
    else
      false
    for need in std.objectFields(needles)
];

// results will contain 'true' or 'null' if a needle was matched. strip out the 'nulls'
local prunedResults = std.prune(results);

// if the number of 'true's left is the same length as the needles, then assume everything was found and return true
if std.count(prunedResults, true) < std.length(needles) then
    false
else
    true
),
Enter fullscreen mode Exit fullscreen mode

and in use...

testCantFindSingleNeedleInALargerObject: {
    local haystack = { k1: 'v1', k2: 'v2', k3: 'v3' },

    local needle = { k4: 'v4' },

    actual: utils.containsObjects(haystack, needle),
    expect: false,
  },
Enter fullscreen mode Exit fullscreen mode

Top comments (0)