- Filtering RSpec tests
- Generating YAML from the chart
- Chart inputs
- Using property merge patterns
- Testing the results
- Tests that require a Kubernetes cluster
- Optimizing test speed
Writing RSpec tests for charts
The following are notes and conventions used for creating RSpec tests for the GitLab chart.
Filtering RSpec tests
To aid in development it is possible to filter which tests are executed by
adding the :focus
tag to one or more tests. With the :focus
tag only
tests that have been specifically tagged will be run. This allows quick
development and testing of new code without having to wait for all the RSpec
tests to execute. The following is an example of a test that has been tagged
with:focus
.
describe 'some feature' do
it 'generates output', :focus => true do
...
end
end
The :focus
tag can be added to describe
, context
or it
blocks which
allows a test or a group of tests to be executed.
Generating YAML from the chart
Much of the testing of the chart is that it generates the correct YAML structure given a number of chart inputs. This is done using the HelmTemplate class as in the following:
obj = HelmTemplate.new(values)
The resulting obj
encodes the YAML documents returned by the helm template
command indexed by the Kubernetes object kind
and the object name (metadata.name
). This indexed
valued is used by most of the methods to locate values within the YAML.
For example:
obj.dig('ConfigMap/test-gitaly', 'data', 'config.toml.tpl')
This will return the contents of the config.toml.tpl
file contained in the
test-gitaly
ConfigMap.
HelmTemplate
class will always use the release name of “test”
when executing the helm template
command.Chart inputs
The input parameter to the HelmTemplate
class constructor is a dictionary
of values that represents the values.yaml
that is used on the Helm command
line. This dictionary mirrors the YAML structure of the values.yaml
file.
describe 'some feature' do
let(:default_values) do
HelmTemplate.defaults
# or:
# HelmTemplate.with_defaults(%(
# yourCustom: values
#))
end
describe 'global.feature.enabled' do
let(:values) do
YAML.safe_load(%(
global:
feature:
enabled: true
)).deep_merge(default_values)
end
...
end
end
The above snippet demonstrates a common pattern of setting a number of default
values that are common across multiple tests that are then merged into the
final values that are used in the HelmTemplate
constructor for a specific
set of tests.
Using property merge patterns
Throughout the RSpec of this project, you will find different forms of merge
. There are a few guidelines and considerations to take into account when choosing which to make use of.
Ruby’s native Hash.merge
will replace keys in the destination, it will not deeply walk an object.
This means that all properties under a tree will be removed if the source has a matching entry.
In an attempt to address, this we’ve been using the hash-deep-merge gem to perform naive deep merge of YAML documents.
When adding properites, this has worked well. The drawback is that this does not provide a means to cause the overwrite of nested structures.
Helm merges / coalesces configuration properties via coalesceValues function, which has some distinctly different behaviors to deep_merge
as implemented here. We continue to refine how this functions within our RSpec.
General guidelines:
- Be aware of and wary of the behavior of
Hash.merge
. - Be aware of and wary of the behavior of
Hash.deep_merge
as offered byhash-deep-merge
gem. - When you need to overwrite a specific key, do so explicitly with non-empty content.
- When you need to remove a specific key, set it to
null
. - Do not use imperative forms (
merge!
) unless expressly needed. When doing so, comment why.
Breakdown of considerations for merge operations
Here is a direct comparison of Ruby’s Hash.merge
versus Hash.deep_merge
from the hash-deep-merge
gem.
2.7.2 :002 > require 'yaml'
=> true
2.7.2 :003"> example = YAML.safe_load(%(
2.7.2 :004"> a:
2.7.2 :005"> b: 1
2.7.2 :006"> c: [ 1, 2, 3]
2.7.2 :007 > ))
=> {"a"=>{"b"=>1, "c"=>[1, 2, 3]}}
2.7.2 :008"> source = YAML.safe_load(%(
2.7.2 :009"> a:
2.7.2 :010"> d: "whee"
2.7.2 :011 > ))
=> {"a"=>{"d"=>"whee"}}
2.7.2 :012 > example.merge(source)
=> {"a"=>{"d"=>"whee"}}
2.7.2 :013 > require 'hash_deep_merge'
2.7.2 :014 > example = {"a"=>{"b"=>1, "c"=>[1, 2, 3]}}
=> {"a"=>{"b"=>1, "c"=>[1, 2, 3]}}
2.7.2 :015 > source = {"a"=>{"b"=> 2, "d"=>"whee"}}
=> {"a"=>{"b"=>2, "d"=>"whee"}}
2.7.2 :016 > example.deep_merge(source)
=> {"a"=>{"b"=>2, "c"=>[1, 2, 3], "d"=>"whee"}}
Let us compare the output of Ruby’s values.deep_merge(xyz)
and that of Helm’s helm template . -f xyz.yaml
, so that we can examine the differences between deep_merge
and coalesceValues
within Helm. The desired behavior is the equivalent of merge.WithOverride
from github.com/darccio/mergo
Go module as used within Helm and Sprig.
The Ruby code for this is effectively:
require 'yaml'
require 'hash_deep_merge'
values = YAML.safe_load(File.read('values.yaml'))
xyz = YAML.safe_load(File.read('xyz.yaml'))
puts values.deep_merge(xyz).to_yaml
---
file: values.yaml
gitlab:
gitaly:
securityContext:
user: 1000
group: 1000
---
file: empty.yaml # sets `securityContext: {}`
gitlab:
gitaly:
securityContext:
user: 1000
group: 1000
---
file: null.yaml # sets `securityContext: null`
gitlab:
gitaly:
securityContext:
---
file: null_user.yaml # sets `securityContext.user: null`
gitlab:
gitaly:
securityContext:
user:
group: 1000
The Helm template contains only {{ .Values | toYaml }}
---
# Source: example/templates/output.yaml
file: values.yaml
gitlab:
gitaly:
securityContext:
group: 1000
user: 1000
---
# Source: example/templates/output.yaml
file: empty.yaml # sets `securityContext: {}`
gitlab:
gitaly:
securityContext:
group: 1000
user: 1000
---
# Source: example/templates/output.yaml
file: null.yaml # sets `securityContext: null`
gitlab:
gitaly: {}
---
# Source: example/templates/output.yaml
file: null_user.yaml # sets `securityContext.user: null`
gitlab:
gitaly:
securityContext:
group: 1000
First observation: When we set an “empty” hash ({}
), both Ruby and Helm patterns result in no change. This is because the base value, and the “new” value are both the same type. To remove a hash, you must set it to null
.
Second observation: This is a stark difference. When we set the hash to null
in the YAML, we get slightly different results. Helm removes the entire key, but leaves the parent type intact. Ruby leaves the key present, but with nil
value. Similar can be seen when we change an individual key. Helm removes this key while Ruby retains it in a nil
state.
Last, but not least! Do not confuse scalars with maps. The following YAML, when merged in Ruby or Helm, will result in the array being []
. Neither deep_merge
or coalesceValues
walks into arrays. Scalar data will be overwritten.
---
complex:
array: [1,2,3]
hash:
item: 1
---
complex:
array: []
hash:
item:
---
# Ruby: puts values.deep_merge(xyz).to_yaml
complex:
array: []
hash:
item:
---
# Source: example/templates/output.yaml
complex:
array: []
hash: {}
Testing the results
The HelmTemplate
object has a number of methods that assist with writing
RSpec tests. The following are a summary of the available methods.
.exit_code()
This returns the exit code of the helm template
command used to create the
YAML documents that instantiates the chart in the Kubernetes cluster. A
successful completion of the helm template
will return an exit code of 0.
.dig(key, ...)
Walk down the YAML document returned by the HelmTemplate
instance and
return the value residing at the last key. If no value is found, then nil
is returned.
.labels(item)
Return a hash of the labels for the specified object.
.template_labels(item)
Return a hash of the labels used in the template structure for the specified object. The specified object should be a Deployment, StatefulSet or a CronJob object.
.annotations(item)
Return a has of the annotations for the specified object.
.template_annotations(item)
Return a hash of the annotations used in the template structure for the specified object. The specified object should be a Deployment, StatefulSet or a CronJob object.
.volumes(item)
Return an array of all the volumes for the specified deployment object. The
returned array is a direct copy of the volumes
key from the deployment
object.
.find_volume(item, volume_name)
Return a dictionary of the specified volume from the specified deployment object.
.projected_volume_sources(item, mount_name)
Return an array of sources for the specified projected volume. The returned array has the following structure:
- secret:
name: test-rails-secret
items:
- key: secrets.yml
path: rails-secrets/secrets.yml
.stderr()
Return the STDERR output from the execution of helm template
command.
.values()
Return a dictionary of all values that were used in the execution of the
helm template
command.
Tests that require a Kubernetes cluster
The majority of the RSpec tests execute helm template
and then analyze
the generated YAML for the correct structures given the feature being
tested. Occasionally an RSpec test requires access to a Kubernetes cluster
with the GitLab Helm chart deployed to it. Tests that interact with the
chart deployed in a Kubernetes cluster should be placed in the features
directory.
If the RSpec tests are being executed and a Kubernetes cluster is not
available, then the tests in the features
directory will be skipped. At
the start of an RSpec run kubectl get nodes
will be checked for results
and if it returns successfully the tests in the features
directory will
be included.
Optimizing test speed
Each it
block runs a Helm template, which is a time and resource intensive
operation. Given the high frequency of these blocks in our RSpec test suites,
we aim to reduce the number of it
blocks where possible.
The RSpec documentation provides further explanation:
Use
let
to define a memoized helper method. The value will be cached across multiple calls in the same example but not across examples.
For example, consider this test refactor:
Before: ~14 seconds to run
let(:template) { HelmTemplate.new(deployments_values) }
it 'properly sets the global ingress provider when not specified' do
expect(template.annotations('Ingress/test-webservice-default')).to include('kubernetes.io/ingress.provider' => 'global-provider')
end
it 'properly sets the local ingress provider when specified' do
expect(template.annotations('Ingress/test-webservice-second')).to include('kubernetes.io/ingress.provider' => 'second-provider')
end
After: ~5 seconds to run
let(:template) { HelmTemplate.new(deployments_values) }
it 'properly sets the ingress provider' do
expect(template.annotations('Ingress/test-webservice-default')).to include('kubernetes.io/ingress.provider' => 'global-provider')
expect(template.annotations('Ingress/test-webservice-second')).to include('kubernetes.io/ingress.provider' => 'second-provider')
end
Consolidating two it
blocks into one leads to significant time savings because it reduces the number of calls to helm template
.