Skip to content
This repository was archived by the owner on Aug 26, 2025. It is now read-only.

Commit c049c2e

Browse files
marshall7mludoo
andauthored
Cache TerraformTest methods (#57)
* add test_pickle.py * add skip duner method cond and __*state__ methods * add linting changes * linting changes attempt 2 * lint changes tftest.py * add .terragrunt-cache to gitignore * add tftest_fixt to setup * add tftest_fixt.py * add terra fixt tests * add terragrunt.hcl to plan_no_resource_changes fixt * rm skip teardown flag and param attr * add base_tester fixt to import tftest fixtures * add tftest_fixt to setup py_modules * rm tftest_fixt from py mod & fix test fixt import * any heredocs and type hints * fix cache hash being difference on every test * autopep8 changes * add .tftest-cache to gitignore * add _cache() * Delete test_terra_fixtures.py * Create test_cache.py * add use_cache arg to other methods * Delete tftest_fixt.py * rm pytest fixture from setup * add cache args to TerragruntTest * add caching section to readme * fix using cache condition * pass use_cache to init() within setup() Co-authored-by: Ludovico Magnocavallo <[email protected]>
1 parent fabc166 commit c049c2e

File tree

6 files changed

+222
-11
lines changed

6 files changed

+222
-11
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ dmypy.json
125125
# Local .terraform directories
126126
**/.terraform/*
127127

128+
# Local .terragrunt directories
129+
**/.terragrunt-cache/*
130+
131+
# Local .tftest-cache directories
132+
**/.tftest-cache/*
133+
128134
# .tfstate files
129135
*.tfstate
130136
*.tfstate.*

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,42 @@ def test_run_all_apply(run_all_apply_out):
7878
assert len(run_all_apply_out) == 3
7979
```
8080

81+
## Caching
82+
83+
The `TerraformTest` `setup`, `init`, `plan`, `apply`, `output` and `destroy` methods have the ability to cache it's associate output to a local `.tftest-cache` directory. For subsequent calls of the method, the cached value can be returned instead of calling the actual underlying `terraform` command. Using the cache value can be significantly faster than running the Terraform command again especially if the command is time-intensive.
84+
85+
To determine if the cache should be used, first a hash value is generated using the current `TerraformTest` instance `__init__` and calling method arguments. The hash value is compared to the hash value of the cached instance's associated arguments. If the hash is the same then the cache is used, otherwise the method is executed.
86+
87+
The benefits of the caching feature include:
88+
- Faster setup time for testing terraform modules that don't change between testing sessions
89+
- Writing tests without worrying about errors within their test code resulting in the Terraform setup logic to run again
90+
91+
Please see the following example for how to use it:
92+
93+
```python
94+
import pytest
95+
import tftest
96+
97+
98+
@pytest.fixture
99+
def output(fixtures_dir):
100+
tf = tftest.TerraformTest('apply', fixtures_dir, enable_cache=True)
101+
tf.setup(use_cache=True)
102+
tf.apply(use_cache=True)
103+
yield tf.output(use_cache=True)
104+
tf.destroy(use_cache=True, **{"auto_approve": True})
105+
106+
107+
def test_apply(output):
108+
value = output['triggers']
109+
assert len(value) == 2
110+
assert list(value[0].keys()) == ['name', 'template']
111+
assert value[0]['name'] == 'one'
112+
113+
```
114+
115+
116+
81117
## Compatibility
82118

83119
Starting from version `1.0.0` Terraform `0.12` is required, and tests written with previous versions of this module are incompatible. Check the [`CHANGELOG.md`](https://github.com/GoogleCloudPlatform/terraform-python-testing-helper/blob/master/CHANGELOG.md) file for details on what's changed.

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,5 @@
3737
"Operating System :: OS Independent",
3838
],
3939
setup_requires=['nose>=1.3'],
40-
test_suite='nose.collector'
40+
test_suite='nose.collector',
4141
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
terraform {
2+
source = get_terragrunt_dir()
3+
}

test/test_cache.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Copyright 2019 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
import os
17+
import shutil
18+
import pytest
19+
import tftest
20+
from unittest.mock import patch, DEFAULT, Mock
21+
22+
pytest_plugins = [
23+
str("_pytest.pytester"),
24+
]
25+
26+
_LOGGER = logging.getLogger('tftest')
27+
28+
cache_methods = ["setup", "init", "plan", "apply", "output", "destroy"]
29+
30+
31+
@pytest.fixture
32+
def tf(request, fixtures_dir):
33+
terra = tftest.TerraformTest(
34+
tfdir='plan_no_resource_changes',
35+
basedir=fixtures_dir,
36+
enable_cache=request.param,
37+
)
38+
yield terra
39+
40+
_LOGGER.debug("Removing cache dir")
41+
try:
42+
shutil.rmtree(terra.cache_dir)
43+
except FileNotFoundError:
44+
_LOGGER.debug("%s does not exists", terra.cache_dir)
45+
46+
47+
@pytest.mark.parametrize("tf", [True], indirect=True)
48+
def test_use_cache(tf):
49+
"""
50+
Ensures cache is used and runs the execute_command() for first call of the
51+
method only
52+
"""
53+
for method in cache_methods:
54+
with patch.object(tf, 'execute_command', wraps=tf.execute_command) as mock_execute_command:
55+
for _ in range(2):
56+
getattr(tf, method)(use_cache=True)
57+
assert mock_execute_command.call_count == 1
58+
59+
60+
@pytest.mark.parametrize("tf", [
61+
pytest.param(
62+
True,
63+
id="enable_cache"
64+
),
65+
pytest.param(
66+
False,
67+
id="disable_cache"
68+
),
69+
], indirect=True)
70+
def test_no_use_cache(tf):
71+
"""
72+
Ensures cache is not used and runs the execute_command() for every call of
73+
the method
74+
"""
75+
expected_call_count = 2
76+
for method in cache_methods:
77+
with patch.object(tf, 'execute_command', wraps=tf.execute_command) as mock_execute_command:
78+
for _ in range(expected_call_count):
79+
getattr(tf, method)(use_cache=False)
80+
assert mock_execute_command.call_count == expected_call_count

tftest.py

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
from functools import partial
4040
from pathlib import Path
4141
from typing import List
42+
import pickle
43+
from hashlib import sha1
44+
import inspect
4245

4346
__version__ = '1.7.3'
4447

@@ -307,9 +310,12 @@ class TerraformTest(object):
307310
directory above the one this module lives in.
308311
binary: path to the Terraform command.
309312
env: a dict with custom environment variables to pass to terraform.
313+
enable_cache: Determines if the caching enabled for specific methods
314+
cache_dir: optional base directory to use for caching, defaults to
315+
the directory of the python file that instantiates this class
310316
"""
311317

312-
def __init__(self, tfdir, basedir=None, binary='terraform', env=None):
318+
def __init__(self, tfdir, basedir=None, binary='terraform', env=None, enable_cache=False, cache_dir=None):
313319
"""Set Terraform folder to operate on, and optional base directory."""
314320
self._basedir = basedir or os.getcwd()
315321
self.binary = binary
@@ -318,6 +324,12 @@ def __init__(self, tfdir, basedir=None, binary='terraform', env=None):
318324
self.tg_run_all = False
319325
self._plan_formatter = lambda out: TerraformPlanOutput(json.loads(out))
320326
self._output_formatter = lambda out: TerraformValueDict(json.loads(out))
327+
self.enable_cache = enable_cache
328+
if not cache_dir:
329+
self.cache_dir = Path(os.path.dirname(
330+
inspect.stack()[1].filename)) / ".tftest-cache"
331+
else:
332+
self.cache_dir = Path(cache_dir)
321333
if env is not None:
322334
self.env.update(env)
323335

@@ -363,9 +375,74 @@ def _abspath(self, path):
363375
"""Make relative path absolute from base dir."""
364376
return path if os.path.isabs(path) else os.path.join(self._basedir, path)
365377

378+
def _cache(func):
379+
def cache(self, **kwargs):
380+
"""
381+
Runs the tftest instance method or retreives the cache value if it exists
382+
383+
Args:
384+
kwargs: Keyword argument that are passed to the decorated method
385+
Returns:
386+
Output of the tftest instance method
387+
"""
388+
_LOGGER.info("Cache decorated method: %s", func.__name__)
389+
390+
if not self.enable_cache:
391+
return func(self, **kwargs)
392+
elif not kwargs.get("use_cache", False):
393+
return func(self, **kwargs)
394+
395+
cache_dir = self.cache_dir / \
396+
Path(self.tfdir.strip("/")) / Path(func.__name__)
397+
# creates cache dir if not exists
398+
cache_dir.mkdir(parents=True, exist_ok=True)
399+
400+
params = {
401+
**{
402+
k: v
403+
for k, v in self.__dict__.items()
404+
# only uses instance attributes that are involved in the results of
405+
# the decorated method
406+
if k in ["binary", "_basedir", "tfdir", "env"]
407+
},
408+
**kwargs,
409+
}
410+
411+
hash_filename = sha1(
412+
json.dumps(params, sort_keys=True, default=str).encode("cp037")
413+
).hexdigest() + ".pickle"
414+
415+
cache_key = cache_dir / hash_filename
416+
_LOGGER.debug("Cache key: %s", cache_key)
417+
418+
try:
419+
f = cache_key.open("rb")
420+
except OSError:
421+
_LOGGER.debug("Could not read cache path")
422+
else:
423+
_LOGGER.info("Getting output from cache")
424+
return pickle.load(f)
425+
426+
_LOGGER.info("Running command")
427+
out = func(self, **kwargs)
428+
429+
if out:
430+
_LOGGER.info("Writing command to cache")
431+
try:
432+
f = cache_key.open("wb")
433+
except OSError as e:
434+
_LOGGER.error("Cache could not write path")
435+
else:
436+
with f:
437+
pickle.dump(out, f, pickle.HIGHEST_PROTOCOL)
438+
439+
return out
440+
return cache
441+
442+
@_cache
366443
def setup(self, extra_files=None, plugin_dir=None, init_vars=None,
367444
backend=True, cleanup_on_exit=True, disable_prevent_destroy=False,
368-
workspace_name=None, **kw):
445+
workspace_name=None, use_cache=False, **kw):
369446
"""Setup method to use in test fixtures.
370447
371448
This method prepares a new Terraform environment for testing the module
@@ -437,13 +514,14 @@ def setup(self, extra_files=None, plugin_dir=None, init_vars=None,
437514
filenames, deep=cleanup_on_exit,
438515
restore_files=disable_prevent_destroy)
439516
setup_output = self.init(plugin_dir=plugin_dir, init_vars=init_vars,
440-
backend=backend, **kw)
517+
backend=backend, use_cache=use_cache, **kw)
441518
if workspace_name:
442519
setup_output += self.workspace(name=workspace_name)
443520
return setup_output
444521

522+
@_cache
445523
def init(self, input=False, color=False, force_copy=False, plugin_dir=None,
446-
init_vars=None, backend=True, **kw):
524+
init_vars=None, backend=True, use_cache=False, **kw):
447525
"""Run Terraform init command."""
448526
cmd_args = parse_args(input=input, color=color, backend=backend,
449527
force_copy=force_copy, plugin_dir=plugin_dir,
@@ -462,8 +540,9 @@ def workspace(self, name=None):
462540
cmd_args = ['new', name]
463541
return self.execute_command('workspace', *cmd_args).out
464542

543+
@_cache
465544
def plan(self, input=False, color=False, refresh=True, tf_vars=None,
466-
targets=None, output=False, tf_var_file=None, **kw):
545+
targets=None, output=False, tf_var_file=None, use_cache=False, **kw):
467546
"""
468547
Run Terraform plan command, optionally returning parsed plan output.
469548
@@ -496,8 +575,9 @@ def plan(self, input=False, color=False, refresh=True, tf_vars=None,
496575
except json.JSONDecodeError as e:
497576
raise TerraformTestError('Error decoding plan output: {}'.format(e))
498577

578+
@_cache
499579
def apply(self, input=False, color=False, auto_approve=True, tf_vars=None,
500-
targets=None, tf_var_file=None, **kw):
580+
targets=None, tf_var_file=None, use_cache=False, **kw):
501581
"""
502582
Run Terraform apply command.
503583
@@ -515,7 +595,8 @@ def apply(self, input=False, color=False, auto_approve=True, tf_vars=None,
515595
tf_var_file=tf_var_file, **kw)
516596
return self.execute_command('apply', *cmd_args).out
517597

518-
def output(self, name=None, color=False, json_format=True, **kw):
598+
@_cache
599+
def output(self, name=None, color=False, json_format=True, use_cache=False, **kw):
519600
"""Run Terraform output command."""
520601
cmd_args = []
521602
if name:
@@ -530,8 +611,9 @@ def output(self, name=None, color=False, json_format=True, **kw):
530611
_LOGGER.warning('error decoding output: {}'.format(e))
531612
return output
532613

614+
@_cache
533615
def destroy(self, color=False, auto_approve=True, tf_vars=None, targets=None,
534-
tf_var_file=None, **kw):
616+
tf_var_file=None, use_cache=False, **kw):
535617
"""Run Terraform destroy command."""
536618
cmd_args = parse_args(color=color, auto_approve=auto_approve,
537619
tf_vars=tf_vars, targets=targets,
@@ -612,7 +694,7 @@ def _parse_run_all_out(output: str, formatter: TerraformJSONBase) -> str:
612694
class TerragruntTest(TerraformTest):
613695

614696
def __init__(self, tfdir, basedir=None, binary='terragrunt', env=None,
615-
tg_run_all=False):
697+
tg_run_all=False, enable_cache=False, cache_dir=None):
616698
"""A helper class that could be used for testing terragrunt
617699
618700
Most operations that apply to :func:`~TerraformTest` also apply to this class.
@@ -629,8 +711,12 @@ def __init__(self, tfdir, basedir=None, binary='terragrunt', env=None,
629711
binary: (Optional) path to terragrunt command.
630712
env: a dict with custom environment variables to pass to terraform.
631713
tg_run_all: whether the test is for terragrunt run-all, default to False
714+
enable_cache: Determines if the caching enabled for specific methods
715+
cache_dir: optional base directory to use for caching, defaults to
716+
the directory of the python file that instantiates this class
632717
"""
633-
TerraformTest.__init__(self, tfdir, basedir, binary, env)
718+
TerraformTest.__init__(self, tfdir, basedir, binary,
719+
env, enable_cache, cache_dir)
634720
self.tg_run_all = tg_run_all
635721
if self.tg_run_all:
636722
self._plan_formatter = partial(_parse_run_all_out,

0 commit comments

Comments
 (0)