Merge ~afreiberger/charm-grafana:add_tests_and_linting into charm-grafana:master

Proposed by Drew Freiberger
Status: Rejected
Rejected by: Alvaro Uria
Proposed branch: ~afreiberger/charm-grafana:add_tests_and_linting
Merge into: charm-grafana:master
Prerequisite: ~afreiberger/charm-grafana:lp#1858490
Diff against target: 754 lines (+514/-27)
15 files modified
.gitignore (+22/-0)
Makefile (+49/-0)
lib/charms/layer/grafana.py (+2/-3)
reactive/grafana.py (+26/-24)
requirements.txt (+1/-0)
tests/functional/conftest.py (+67/-0)
tests/functional/juju_tools.py (+68/-0)
tests/functional/requirements.txt (+6/-0)
tests/functional/test_deploy.py (+103/-0)
tests/unit/conftest.py (+69/-0)
tests/unit/example.cfg (+1/-0)
tests/unit/requirements.txt (+5/-0)
tests/unit/test_actions.py (+12/-0)
tests/unit/test_lib.py (+12/-0)
tox.ini (+71/-0)
Reviewer Review Type Date Requested Status
Alvaro Uria (community) Disapprove
Peter Sabaini (community) Needs Fixing
Canonical IS Reviewers Pending
Canonical IS Reviewers Pending
Review via email: mp+377472@code.launchpad.net

This proposal supersedes a proposal from 2020-01-11.

Commit message

Added testing and resolved lint errors

To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : Posted in a previous version of this proposal

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : Posted in a previous version of this proposal

Unable to determine commit message from repository - please click "Set commit message" and enter the commit message manually.

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Unable to determine commit message from repository - please click "Set commit message" and enter the commit message manually.

Revision history for this message
Peter Sabaini (peter-sabaini) wrote :

Hi,

generally looking good. However I get two unit test errors, one is a left-over example, the other missing fixture:

file /home/peter/src/charms/grafana-charm/tests/unit/test_lib.py, line 8
      def test_grafana(self, grafana): E fixture 'grafana' not found > available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, cov, doctest_namespace, mock_charm_dir, mock_hookenv_config, mock_layers, mock_remote_unit, monkeypatch, no_cover, pytestconfig, record_property, rec
ord_testsuite_property, record_xml_attribute, recwarn, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.

I've marked some more places with boilerplate code

cheers,
peter.

review: Needs Fixing
Revision history for this message
Peter Sabaini (peter-sabaini) wrote :

Forgot to note that the example-action test also failing in functional testing

review: Needs Fixing
Revision history for this message
Drew Freiberger (afreiberger) wrote :

Thanks for the review. I think I'll start with the unit_testing from guoqian's codebase. This was definitely WIP stuff I started friday afternoon and just wanted to get into the repo and test MRs with merge dependencies.

Thank you for the comments!

Revision history for this message
Drew Freiberger (afreiberger) :
Revision history for this message
Alvaro Uria (aluria) wrote :

I have worked on adding tests starting from this branch. Since most of the code was a port from template-python-pytest, I have removed the unit tests (no real testing being done) and updated the functional tests following my work at [1].

I will go ahead and reject this change, and will propose a new fix soon.

1. https://bugs.launchpad.net/charm-grafana/+bug/1822329

review: Disapprove

Unmerged commits

d3370e1... by Drew Freiberger

Added testing and resolved lint errors

WIP

f2a62d8... by Drew Freiberger

Check dashboards before uploading new revisions

It was found that dashboards were being uploaded and creating unbounded
revision history once every 5 minutes during update-status causing the
grafana.db configuration database to balloon. To eliminate this, we
now validate that the rendered dashboard template result is not already
the version available in the grafana database before uploading.

Some refactoring of the dashboard function has been made to resolve
complexity warnings.

Closes-Bug: 1858490

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 0000000..32e2995
4--- /dev/null
5+++ b/.gitignore
6@@ -0,0 +1,22 @@
7+# Byte-compiled / optimized / DLL files
8+__pycache__/
9+*.py[cod]
10+*$py.class
11+
12+# Log files
13+*.log
14+
15+.tox/
16+.coverage
17+
18+# vi
19+.*.swp
20+
21+# pycharm
22+.idea/
23+
24+# version data
25+repo-info
26+
27+# reports
28+report/*
29diff --git a/Makefile b/Makefile
30new file mode 100644
31index 0000000..b357248
32--- /dev/null
33+++ b/Makefile
34@@ -0,0 +1,49 @@
35+help:
36+ @echo "This project supports the following targets"
37+ @echo ""
38+ @echo " make help - show this text"
39+ @echo " make submodules - make sure that the submodules are up-to-date"
40+ @echo " make lint - run flake8"
41+ @echo " make test - run the unittests and lint"
42+ @echo " make unittest - run the tests defined in the unittest subdirectory"
43+ @echo " make functional - run the tests defined in the functional subdirectory"
44+ @echo " make release - build the charm"
45+ @echo " make clean - remove unneeded files"
46+ @echo ""
47+
48+submodules:
49+ @echo "Cloning submodules"
50+ @git submodule update --init --recursive
51+
52+lint:
53+ @echo "Running flake8"
54+ @tox -e lint
55+
56+test: lint unittest functional
57+
58+unittest:
59+ @tox -e unit
60+
61+functional: build
62+ @PYTEST_KEEP_MODEL=$(PYTEST_KEEP_MODEL) \
63+ PYTEST_CLOUD_NAME=$(PYTEST_CLOUD_NAME) \
64+ PYTEST_CLOUD_REGION=$(PYTEST_CLOUD_REGION) \
65+ tox -e functional
66+
67+build:
68+ @echo "Building charm to base directory $(JUJU_REPOSITORY)"
69+ @-git describe --tags > ./repo-info
70+ @CHARM_LAYERS_DIR=./layers CHARM_INTERFACES_DIR=./interfaces TERM=linux \
71+ JUJU_REPOSITORY=$(JUJU_REPOSITORY) charm build . --force
72+
73+release: clean build
74+ @echo "Charm is built at $(JUJU_REPOSITORY)/builds"
75+
76+clean:
77+ @echo "Cleaning files"
78+ @if [ -d .tox ] ; then rm -r .tox ; fi
79+ @if [ -d .pytest_cache ] ; then rm -r .pytest_cache ; fi
80+ @find . -iname __pycache__ -exec rm -r {} +
81+
82+# The targets below don't depend on a file
83+.PHONY: lint test unittest functional build release clean help submodules
84diff --git a/lib/charms/layer/grafana.py b/lib/charms/layer/grafana.py
85index b482203..67b53ea 100644
86--- a/lib/charms/layer/grafana.py
87+++ b/lib/charms/layer/grafana.py
88@@ -2,13 +2,12 @@
89
90 import json
91 import requests
92+from charmhelpers.core import unitdata
93 from charmhelpers.core.hookenv import (
94 config,
95 log,
96 )
97
98-from charmhelpers.core import unitdata
99-
100
101 def get_admin_password():
102 kv = unitdata.kv()
103@@ -30,7 +29,7 @@ def import_dashboard(dashboard, name=None):
104 name = dashboard['dashboard'].get('title') or 'Untitled'
105 headers = {'Content-Type': 'application/json'}
106 import_url = 'http://localhost:{}/api/dashboards/db'.format(
107- config('port'))
108+ config('port'))
109 passwd = get_admin_password()
110 if passwd is None:
111 return (False, 'Unable to retrieve grafana password.')
112diff --git a/reactive/grafana.py b/reactive/grafana.py
113index fbc53b8..3f24f0a 100644
114--- a/reactive/grafana.py
115+++ b/reactive/grafana.py
116@@ -4,13 +4,11 @@ import glob
117 import json
118 import os
119 import re
120-import requests
121 import shutil
122-import six
123 import subprocess
124 import time
125-from jsondiff import diff
126
127+from charmhelpers import fetch
128 from charmhelpers.contrib.charmsupport import nrpe
129 from charmhelpers.core import (
130 hookenv,
131@@ -18,11 +16,9 @@ from charmhelpers.core import (
132 unitdata,
133 )
134 from charmhelpers.core.templating import render
135-from charmhelpers import fetch
136-from charms.reactive.helpers import (
137- any_file_changed,
138- is_state,
139-)
140+
141+from charms.layer import snap
142+from charms.layer.grafana import import_dashboard
143 from charms.reactive import (
144 hook,
145 remove_state,
146@@ -30,11 +26,20 @@ from charms.reactive import (
147 when,
148 when_not,
149 )
150+from charms.reactive.helpers import (
151+ any_file_changed,
152+ is_state,
153+)
154
155-from charms.layer import snap
156-from charms.layer.grafana import import_dashboard
157 from jinja2 import Environment, FileSystemLoader, exceptions
158
159+from jsondiff import diff
160+
161+import requests
162+
163+import six
164+
165+
166 SVCNAME = {'snap': 'snap.grafana.grafana',
167 'apt': 'grafana-server'}
168 SNAP_NAME = 'grafana'
169@@ -120,8 +125,7 @@ def install_packages():
170 set_state('grafana.installed')
171 hookenv.status_set('active', 'Completed installing grafana')
172 elif source == 'snap' and \
173- (host.lsb_release()['DISTRIB_CODENAME'] >= 'xenial' or
174- host.lsb_release()['DISTRIB_CODENAME'] < 'p'):
175+ (host.lsb_release()['DISTRIB_CODENAME'] >= 'xenial' or host.lsb_release()['DISTRIB_CODENAME'] < 'p'):
176 # NOTE(aluria): precise is the last supported Ubuntu release, so
177 # anything below 'p' is actually newer than xenial (systemd support)
178 snap.install(SNAP_NAME, channel=channel, force_dangerous=False)
179@@ -433,8 +437,9 @@ def configure_website(website):
180
181
182 def validate_datasources():
183- """TODO: make sure datasources option is merged with
184- relation data
185+ """Verify that datasources configuration is valid, if existing.
186+
187+ TODO: make sure datasources option is merged with relation data
188 TODO: make sure datasources are validated
189 """
190 config = hookenv.config()
191@@ -448,7 +453,8 @@ def validate_datasources():
192
193
194 def check_datasource(ds):
195- """
196+ """Check for and add datasources not currently in grafana DB.
197+
198 CREATE TABLE `data_source` (
199 `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL
200 , `org_id` INTEGER NOT NULL
201@@ -470,7 +476,6 @@ def check_datasource(ds):
202 , `with_credentials` INTEGER NOT NULL DEFAULT 0);
203 INSERT INTO "data_source" VALUES(1,1,0,'prometheus','BootStack Prometheus','proxy','http://localhost:9090','','','',0,'','',1,'{}','2016-01-22 12:11:06','2016-01-22 12:11:11',0);
204 """ # noqa E501
205-
206 # ds will be similar to:
207 # {'service_name': 'prometheus',
208 # 'url': 'http://10.0.3.216:9090',
209@@ -505,9 +510,8 @@ def check_datasource(ds):
210
211 # This isn't exposed in charmhelpers: https://github.com/juju/charm-helpers/issues/367
212 def render_custom(source, context, **parameters):
213- """
214- Renders a template from the template folder with custom environment
215- parameters.
216+ """Render a template from the template folder with custom environment parameters.
217+
218 source: template file name to render from
219 context: template context variables
220 parameters: initialization parameters for the jinja Environment
221@@ -703,7 +707,8 @@ def generate_query(ds, is_default, id=None):
222 @when('grafana.started')
223 @when_not('grafana.admin_password.set')
224 def check_adminuser():
225- """
226+ """Create Adminuser if not existing.
227+
228 CREATE TABLE `user` (
229 `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL
230 , `version` INTEGER NOT NULL
231@@ -723,7 +728,6 @@ def check_adminuser():
232 );
233 INSERT INTO "user" VALUES(1,0,'admin','root+bootstack-ps45@canonical.com','BootStack Team','309bc4e78bc60d02dc0371d9e9fa6bf9a809d5dc25c745b9e3f85c3ed49c6feccd4ffc96d1db922f4297663a209e93f7f2b6','LZeJ3nSdrC','hseJcLcnPN','',1,1,0,'light','2016-01-22 12:00:08','2016-01-22 12:02:13');
234 """ # noqa E501
235-
236 # XXX: If you add any dependencies on config items here,
237 # be sure to update config_changed() accordingly!
238
239@@ -759,9 +763,7 @@ def check_adminuser():
240 query = cur.execute('SELECT id, login, salt FROM user')
241 for row in query.fetchall():
242 if row[1] == 'admin':
243- nagios_context = config.get('nagios_context', False)
244- if not nagios_context:
245- nagios_context = 'UNKNOWN'
246+ nagios_context = config.get('nagios_context', 'UNKNOWN')
247 email = 'root+%s@canonical.com' % nagios_context
248 hpasswd = hpwgen(passwd, row[2])
249 if hpasswd:
250diff --git a/requirements.txt b/requirements.txt
251new file mode 100644
252index 0000000..8462291
253--- /dev/null
254+++ b/requirements.txt
255@@ -0,0 +1 @@
256+# Include python requirements here
257diff --git a/tests/functional/__pycache__/conftest.cpython-37-pytest-5.3.2.pyc b/tests/functional/__pycache__/conftest.cpython-37-pytest-5.3.2.pyc
258new file mode 100644
259index 0000000..54c4d14
260Binary files /dev/null and b/tests/functional/__pycache__/conftest.cpython-37-pytest-5.3.2.pyc differ
261diff --git a/tests/functional/__pycache__/juju_tools.cpython-37.pyc b/tests/functional/__pycache__/juju_tools.cpython-37.pyc
262new file mode 100644
263index 0000000..ce8ffdc
264Binary files /dev/null and b/tests/functional/__pycache__/juju_tools.cpython-37.pyc differ
265diff --git a/tests/functional/__pycache__/test_deploy.cpython-37-pytest-5.3.2.pyc b/tests/functional/__pycache__/test_deploy.cpython-37-pytest-5.3.2.pyc
266new file mode 100644
267index 0000000..27b64d7
268Binary files /dev/null and b/tests/functional/__pycache__/test_deploy.cpython-37-pytest-5.3.2.pyc differ
269diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
270new file mode 100644
271index 0000000..1d2d6e9
272--- /dev/null
273+++ b/tests/functional/conftest.py
274@@ -0,0 +1,67 @@
275+#!/usr/bin/python3
276+"""
277+Reusable pytest fixtures for functional testing
278+
279+Environment variables
280+---------------------
281+
282+PYTEST_CLOUD_REGION, PYTEST_CLOUD_NAME: cloud name and region to use for juju model creation
283+
284+PYTEST_KEEP_MODEL: if set, the testing model won't be torn down at the end of the testing session
285+
286+"""
287+
288+import asyncio
289+import os
290+import uuid
291+import pytest
292+import subprocess
293+
294+from juju.controller import Controller
295+from juju_tools import JujuTools
296+
297+
298+@pytest.fixture(scope='module')
299+def event_loop():
300+ """Override the default pytest event loop to allow for fixtures using a broader scope"""
301+ loop = asyncio.get_event_loop_policy().new_event_loop()
302+ asyncio.set_event_loop(loop)
303+ loop.set_debug(True)
304+ yield loop
305+ loop.close()
306+ asyncio.set_event_loop(None)
307+
308+
309+@pytest.fixture(scope='module')
310+async def controller():
311+ """Connect to the current controller"""
312+ _controller = Controller()
313+ await _controller.connect_current()
314+ yield _controller
315+ await _controller.disconnect()
316+
317+
318+@pytest.fixture(scope='module')
319+async def model(controller):
320+ """This model lives only for the duration of the test"""
321+ model_name = "functest-{}".format(str(uuid.uuid4())[-12:])
322+ _model = await controller.add_model(model_name,
323+ cloud_name=os.getenv('PYTEST_CLOUD_NAME'),
324+ region=os.getenv('PYTEST_CLOUD_REGION'),
325+ )
326+ # https://github.com/juju/python-libjuju/issues/267
327+ subprocess.check_call(['juju', 'models'])
328+ while model_name not in await controller.list_models():
329+ await asyncio.sleep(1)
330+ yield _model
331+ await _model.disconnect()
332+ if not os.getenv('PYTEST_KEEP_MODEL'):
333+ await controller.destroy_model(model_name)
334+ while model_name in await controller.list_models():
335+ await asyncio.sleep(1)
336+
337+
338+@pytest.fixture(scope='module')
339+async def jujutools(controller, model):
340+ tools = JujuTools(controller, model)
341+ return tools
342diff --git a/tests/functional/juju_tools.py b/tests/functional/juju_tools.py
343new file mode 100644
344index 0000000..4b4884f
345--- /dev/null
346+++ b/tests/functional/juju_tools.py
347@@ -0,0 +1,68 @@
348+import pickle
349+import juju
350+import base64
351+
352+# from juju.errors import JujuError
353+
354+
355+class JujuTools:
356+ def __init__(self, controller, model):
357+ self.controller = controller
358+ self.model = model
359+
360+ async def run_command(self, cmd, target):
361+ """
362+ Runs a command on a unit.
363+
364+ :param cmd: Command to be run
365+ :param unit: Unit object or unit name string
366+ """
367+ unit = (
368+ target
369+ if isinstance(target, juju.unit.Unit)
370+ else await self.get_unit(target)
371+ )
372+ action = await unit.run(cmd)
373+ return action.results
374+
375+ async def remote_object(self, imports, remote_cmd, target):
376+ """
377+ Runs command on target machine and returns a python object of the result
378+
379+ :param imports: Imports needed for the command to run
380+ :param remote_cmd: The python command to execute
381+ :param target: Unit object or unit name string
382+ """
383+ python3 = "python3 -c '{}'"
384+ python_cmd = ('import pickle;'
385+ 'import base64;'
386+ '{}'
387+ 'print(base64.b64encode(pickle.dumps({})), end="")'
388+ .format(imports, remote_cmd))
389+ cmd = python3.format(python_cmd)
390+ results = await self.run_command(cmd, target)
391+ return pickle.loads(base64.b64decode(bytes(results['Stdout'][2:-1], 'utf8')))
392+
393+ async def file_stat(self, path, target):
394+ """
395+ Runs stat on a file
396+
397+ :param path: File path
398+ :param target: Unit object or unit name string
399+ """
400+ imports = 'import os;'
401+ python_cmd = ('os.stat("{}")'
402+ .format(path))
403+ print("Calling remote cmd: " + python_cmd)
404+ return await self.remote_object(imports, python_cmd, target)
405+
406+ async def file_contents(self, path, target):
407+ """
408+ Returns the contents of a file
409+
410+ :param path: File path
411+ :param target: Unit object or unit name string
412+ """
413+ cmd = 'cat {}'.format(path)
414+ result = await self.run_command(cmd, target)
415+ return result['Stdout']
416diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
417new file mode 100644
418index 0000000..f76bfbb
419--- /dev/null
420+++ b/tests/functional/requirements.txt
421@@ -0,0 +1,6 @@
422+flake8
423+juju
424+mock
425+pytest
426+pytest-asyncio
427+requests
428diff --git a/tests/functional/test_deploy.py b/tests/functional/test_deploy.py
429new file mode 100644
430index 0000000..bf28c17
431--- /dev/null
432+++ b/tests/functional/test_deploy.py
433@@ -0,0 +1,103 @@
434+import os
435+import pytest
436+import subprocess
437+import stat
438+
439+# Treat all tests as coroutines
440+pytestmark = pytest.mark.asyncio
441+
442+juju_repository = os.getenv('JUJU_REPOSITORY', '.').rstrip('/')
443+series = ['xenial',
444+ 'bionic',
445+ pytest.param('eoan', marks=pytest.mark.xfail(reason='canary')),
446+ ]
447+sources = [('local', '{}/builds/grafana'.format(juju_repository)),
448+ # ('jujucharms', 'cs:...'),
449+ ]
450+
451+
452+# Uncomment for re-using the current model, useful for debugging functional tests
453+# @pytest.fixture(scope='module')
454+# async def model():
455+# from juju.model import Model
456+# model = Model()
457+# await model.connect_current()
458+# yield model
459+# await model.disconnect()
460+
461+
462+# Custom fixtures
463+@pytest.fixture(params=series)
464+def series(request):
465+ return request.param
466+
467+
468+@pytest.fixture(params=sources, ids=[s[0] for s in sources])
469+def source(request):
470+ return request.param
471+
472+
473+@pytest.fixture
474+async def app(model, series, source):
475+ app_name = 'grafana-{}-{}'.format(series, source[0])
476+ return await model._wait_for_new('application', app_name)
477+
478+
479+async def test_grafana_deploy(model, series, source, request):
480+ # Starts a deploy for each series
481+ # Using subprocess b/c libjuju fails with JAAS
482+ # https://github.com/juju/python-libjuju/issues/221
483+ application_name = 'grafana-{}-{}'.format(series, source[0])
484+ cmd = ['juju', 'deploy', source[1], '-m', model.info.name,
485+ '--series', series, application_name]
486+ if request.node.get_closest_marker('xfail'):
487+ # If series is 'xfail' force install to allow testing against versions not in
488+ # metadata.yaml
489+ cmd.append('--force')
490+ subprocess.check_call(cmd)
491+
492+
493+async def test_charm_upgrade(model, app):
494+ if app.name.endswith('local'):
495+ pytest.skip("No need to upgrade the local deploy")
496+ unit = app.units[0]
497+ await model.block_until(lambda: unit.agent_status == 'idle')
498+ subprocess.check_call(['juju',
499+ 'upgrade-charm',
500+ '--switch={}'.format(sources[0][1]),
501+ '-m', model.info.name,
502+ app.name,
503+ ])
504+ await model.block_until(lambda: unit.agent_status == 'executing')
505+
506+
507+# Tests
508+async def test_grafana_status(model, app):
509+ # Verifies status for all deployed series of the charm
510+ await model.block_until(lambda: app.status == 'active')
511+ unit = app.units[0]
512+ await model.block_until(lambda: unit.agent_status == 'idle')
513+
514+
515+async def test_example_action(app):
516+ unit = app.units[0]
517+ action = await unit.run_action('example-action')
518+ action = await action.wait()
519+ assert action.status == 'completed'
520+
521+
522+async def test_run_command(app, jujutools):
523+ unit = app.units[0]
524+ cmd = 'hostname -i'
525+ results = await jujutools.run_command(cmd, unit)
526+ assert results['Code'] == '0'
527+ assert unit.public_address in results['Stdout']
528+
529+
530+async def test_file_stat(app, jujutools):
531+ unit = app.units[0]
532+ path = '/var/lib/juju/agents/unit-{}/charm/metadata.yaml'.format(unit.entity_id.replace('/', '-'))
533+ fstat = await jujutools.file_stat(path, unit)
534+ assert stat.filemode(fstat.st_mode) == '-rw-r--r--'
535+ assert fstat.st_uid == 0
536+ assert fstat.st_gid == 0
537diff --git a/tests/unit/__pycache__/conftest.cpython-37-pytest-5.3.2.pyc b/tests/unit/__pycache__/conftest.cpython-37-pytest-5.3.2.pyc
538new file mode 100644
539index 0000000..f22ae79
540Binary files /dev/null and b/tests/unit/__pycache__/conftest.cpython-37-pytest-5.3.2.pyc differ
541diff --git a/tests/unit/__pycache__/test_actions.cpython-37-pytest-5.3.2.pyc b/tests/unit/__pycache__/test_actions.cpython-37-pytest-5.3.2.pyc
542new file mode 100644
543index 0000000..dec8696
544Binary files /dev/null and b/tests/unit/__pycache__/test_actions.cpython-37-pytest-5.3.2.pyc differ
545diff --git a/tests/unit/__pycache__/test_lib.cpython-37-pytest-5.3.2.pyc b/tests/unit/__pycache__/test_lib.cpython-37-pytest-5.3.2.pyc
546new file mode 100644
547index 0000000..0ef154b
548Binary files /dev/null and b/tests/unit/__pycache__/test_lib.cpython-37-pytest-5.3.2.pyc differ
549diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
550new file mode 100644
551index 0000000..a2a5470
552--- /dev/null
553+++ b/tests/unit/conftest.py
554@@ -0,0 +1,69 @@
555+#!/usr/bin/python3
556+import mock
557+import pytest
558+
559+
560+# If layer options are used, add this to ${fixture}
561+# and import layer in ${libfile}
562+@pytest.fixture
563+def mock_layers(monkeypatch):
564+ import sys
565+ sys.modules['charms.layer'] = mock.Mock()
566+ sys.modules['reactive'] = mock.Mock()
567+ # Mock any functions in layers that need to be mocked here
568+
569+ def options(layer):
570+ # mock options for layers here
571+ if layer == 'example-layer':
572+ options = {'port': 9999}
573+ return options
574+ else:
575+ return None
576+
577+ monkeypatch.setattr('${libfile}.layer.options', options)
578+
579+
580+@pytest.fixture
581+def mock_hookenv_config(monkeypatch):
582+ import yaml
583+
584+ def mock_config():
585+ cfg = {}
586+ yml = yaml.load(open('./config.yaml'))
587+
588+ # Load all defaults
589+ for key, value in yml['options'].items():
590+ cfg[key] = value['default']
591+
592+ # Manually add cfg from other layers
593+ # cfg['my-other-layer'] = 'mock'
594+ return cfg
595+
596+ monkeypatch.setattr('${libfile}.hookenv.config', mock_config)
597+
598+
599+@pytest.fixture
600+def mock_remote_unit(monkeypatch):
601+ monkeypatch.setattr('${libfile}.hookenv.remote_unit', lambda: 'unit-mock/0')
602+
603+
604+@pytest.fixture
605+def mock_charm_dir(monkeypatch):
606+ monkeypatch.setattr('${libfile}.hookenv.charm_dir', lambda: '/mock/charm/dir')
607+
608+
609+# @pytest.fixture
610+# def ${fixture}(tmpdir, mock_hookenv_config, mock_charm_dir, monkeypatch):
611+# from $libfile import $libclass
612+# helper = ${libclass}()
613+#
614+# # Example config file patching
615+# cfg_file = tmpdir.join('example.cfg')
616+# with open('./tests/unit/example.cfg', 'r') as src_file:
617+# cfg_file.write(src_file.read())
618+# helper.example_config_file = cfg_file.strpath
619+#
620+# # Any other functions that load helper will get this version
621+# monkeypatch.setattr('${libfile}.${libclass}', lambda: helper)
622+#
623+# return helper
624diff --git a/tests/unit/example.cfg b/tests/unit/example.cfg
625new file mode 100644
626index 0000000..81b1e94
627--- /dev/null
628+++ b/tests/unit/example.cfg
629@@ -0,0 +1 @@
630+This is an example config file included with the unit tests
631diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
632new file mode 100644
633index 0000000..9c685e5
634--- /dev/null
635+++ b/tests/unit/requirements.txt
636@@ -0,0 +1,5 @@
637+charmhelpers
638+charms.reactive
639+mock
640+pytest
641+pytest-cov
642diff --git a/tests/unit/test_actions.py b/tests/unit/test_actions.py
643new file mode 100644
644index 0000000..6e7cddb
645--- /dev/null
646+++ b/tests/unit/test_actions.py
647@@ -0,0 +1,12 @@
648+import imp
649+
650+import mock
651+
652+
653+class TestActions():
654+ def test_example_action(self, my_action, monkeypatch):
655+ mock_function = mock.Mock()
656+ monkeypatch.setattr(my_action, 'action_function', mock_function)
657+ assert mock_function.call_count == 0
658+ imp.load_source('action_function', './actions/example-action')
659+ assert mock_function.call_count == 1
660diff --git a/tests/unit/test_lib.py b/tests/unit/test_lib.py
661new file mode 100644
662index 0000000..a7b2b08
663--- /dev/null
664+++ b/tests/unit/test_lib.py
665@@ -0,0 +1,12 @@
666+#!/usr/bin/python3
667+
668+
669+class TestLib():
670+ def test_pytest(self):
671+ assert True
672+
673+ def test_grafana(self, grafana):
674+ """See if the helper fixture works to load charm configs."""
675+ assert isinstance(grafana.charm_config, dict)
676+
677+ # Include tests for functions in ${libfile}
678diff --git a/tox.ini b/tox.ini
679new file mode 100644
680index 0000000..8b4adc3
681--- /dev/null
682+++ b/tox.ini
683@@ -0,0 +1,71 @@
684+[tox]
685+skipsdist=True
686+envlist = unit, functional
687+skip_missing_interpreters = True
688+
689+[testenv]
690+basepython = python3
691+setenv =
692+ PYTHONPATH = .
693+
694+[testenv:unit]
695+commands = pytest -v --ignore {toxinidir}/tests/functional \
696+ --cov=lib \
697+ --cov=reactive \
698+ --cov=actions \
699+ --cov-report=term \
700+ --cov-report=annotate:report/annotated \
701+ --cov-report=html:report/html
702+deps = -r{toxinidir}/tests/unit/requirements.txt
703+ -r{toxinidir}/requirements.txt
704+setenv = PYTHONPATH={toxinidir}/lib
705+
706+[testenv:functional]
707+passenv =
708+ HOME
709+ JUJU_REPOSITORY
710+ PATH
711+ PYTEST_KEEP_MODEL
712+ PYTEST_CLOUD_NAME
713+ PYTEST_CLOUD_REGION
714+commands = pytest -v --ignore {toxinidir}/tests/unit
715+deps = -r{toxinidir}/tests/functional/requirements.txt
716+ -r{toxinidir}/requirements.txt
717+
718+[testenv:lint]
719+commands = flake8
720+deps =
721+ flake8
722+ flake8-docstrings
723+ flake8-import-order
724+ pep8-naming
725+ flake8-colors
726+
727+[flake8]
728+exclude =
729+ .git,
730+ __pycache__,
731+ .tox,
732+# H405: Multi line docstrings should start with a one line summary followed by
733+# an empty line.
734+# D100: Missing docstring in public module
735+# D101: Missing docstring in public class
736+# D102: Missing docstring in public method
737+# D103: Missing docstring in public function
738+# D104: Missing docstring in public package
739+# D105: Missing docstring in magic method
740+# D107: Missing docstring in __init__
741+# D200: One-line docstring should fit on one line with quotes
742+# D202: No blank lines allowed after function docstring
743+# D203: 1 blank required before class docstring
744+# D204: 1 blank line required after class docstring
745+# D205: 1 blank line required between summary line and description
746+# D208: Docstring is over-indented
747+# D400: First line should end with a period
748+# D401: First line should be in imperative mood
749+# I201: Missing newline between import groups
750+# I100: Import statements are in the wrong order
751+
752+ignore = H405,D100,D101,D102,D103,D104,D105,D107,D200,D202,D203,D204,D205,D208,D400,D401,I100,I201
753+max-line-length = 120
754+max-complexity = 10

Subscribers

People subscribed via source and target branches

to all changes: