← Back to team overview

nagios-charmers team mailing list archive

[Merge] ~addyess/charm-nagios:modern_testing into charm-nagios:master

 

Adam Dyess has proposed merging ~addyess/charm-nagios:modern_testing into charm-nagios:master.

Commit message:
Adding unit test and functional tests framework

Requested reviews:
  Nagios Charm developers (nagios-charmers)

For more details, see:
https://code.launchpad.net/~addyess/charm-nagios/+git/charm-nagios/+merge/387241
-- 
Your team Nagios Charm developers is requested to review the proposed merge of ~addyess/charm-nagios:modern_testing into charm-nagios:master.
diff --git a/.gitignore b/.gitignore
index 0ab81b3..ceabe0d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,5 +7,7 @@ data/*.key
 data/*.crt
 data/*.csr
 .tox/
-.idea
+.idea/
+reports/
+.coverage
 repo-info
diff --git a/Makefile b/Makefile
index dbbeab3..7ad8e68 100644
--- a/Makefile
+++ b/Makefile
@@ -11,6 +11,8 @@ endif
 default:
 	echo Nothing to do
 
+test: lint unittest functional
+
 lint:
 	@echo "Running flake8"
 	@tox -e lint
@@ -22,16 +24,15 @@ build:
 	@cp -r * $(CHARM_BUILD_DIR)/$(CHARM_NAME)
 
 # Primitive test runner. Someone please fix this.
-test:
-	tests/00-setup
-	tests/100-deploy
-	tests/10-initial-test
-	tests/15-nrpe-test
-	tests/20-ssl-test
-	tests/21-monitors-interface-test
-	tests/22-extraconfig-test
-	tests/23-livestatus-test
-	tests/24-pagerduty-test
+functional: build
+	@PYTEST_KEEP_MODEL=$(PYTEST_KEEP_MODEL) \
+	 PYTEST_CLOUD_NAME=$(PYTEST_CLOUD_NAME) \
+	 PYTEST_CLOUD_REGION=$(PYTEST_CLOUD_REGION) \
+	 tox -e functional
+
+unittest:
+	@tox -e unit
+
 
 bin/charm_helpers_sync.py:
 	@mkdir -p bin
diff --git a/hooks/monitors-relation-broken b/hooks/monitors-relation-broken
index 880e0af..47150e0 120000
--- a/hooks/monitors-relation-broken
+++ b/hooks/monitors-relation-broken
@@ -1 +1 @@
-monitors-relation-changed
\ No newline at end of file
+monitors_relation_changed.py
\ No newline at end of file
diff --git a/hooks/monitors-relation-changed b/hooks/monitors-relation-changed
new file mode 120000
index 0000000..47150e0
--- /dev/null
+++ b/hooks/monitors-relation-changed
@@ -0,0 +1 @@
+monitors_relation_changed.py
\ No newline at end of file
diff --git a/hooks/monitors-relation-departed b/hooks/monitors-relation-departed
index 880e0af..47150e0 120000
--- a/hooks/monitors-relation-departed
+++ b/hooks/monitors-relation-departed
@@ -1 +1 @@
-monitors-relation-changed
\ No newline at end of file
+monitors_relation_changed.py
\ No newline at end of file
diff --git a/hooks/monitors-relation-changed b/hooks/monitors_relation_changed.py
similarity index 100%
rename from hooks/monitors-relation-changed
rename to hooks/monitors_relation_changed.py
diff --git a/hooks/test-common.py b/hooks/test-common.py
deleted file mode 100644
index b7be677..0000000
--- a/hooks/test-common.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from common import ObjectTagCollection
-import os
-
-from tempfile import NamedTemporaryFile
-
-""" This is meant to test the ObjectTagCollection bits. It should
-    probably be made into a proper unit test. """
-
-x = ObjectTagCollection('test-units')
-y = ObjectTagCollection('test-relids')
-
-o = NamedTemporaryFile(delete=False)
-o2 = NamedTemporaryFile(delete=False)
-o3 = NamedTemporaryFile(delete=True)
-o.write('some content')
-o.flush()
-
-x.tag_object(o.name, 'box-9')
-x.tag_object(o.name, 'nrpe-1')
-y.tag_object(o.name, 'monitors:2')
-x.tag_object(o2.name, 'box-10')
-x.tag_object(o2.name, 'nrpe-2')
-y.tag_object(o2.name, 'monitors:2')
-x.tag_object(o3.name, 'other-0')
-y.tag_object(o3.name, 'monitors:3')
-x.untag_object(o.name, 'box-9')
-x.cleanup_untagged()
-
-if not os.path.exists(o.name):
-    raise RuntimeError(o.name)
-
-x.kill_tag('nrpe-1')
-x.cleanup_untagged()
-
-if os.path.exists(o.name):
-    raise RuntimeError(o.name)
-
-if not os.path.exists(o2.name):
-    raise RuntimeError(o2.name)
-
-y.kill_tag('monitors:2')
-y.cleanup_untagged(['monitors:1', 'monitors:3'])
-
-if os.path.exists(o.name):
-    raise RuntimeError(o2.name)
-
-if os.path.exists(o2.name):
-    raise RuntimeError(o2.name)
-
-x.destroy()
diff --git a/hooks/upgrade-charm b/hooks/upgrade-charm
new file mode 120000
index 0000000..d378ed9
--- /dev/null
+++ b/hooks/upgrade-charm
@@ -0,0 +1 @@
+upgrade_charm.py
\ No newline at end of file
diff --git a/hooks/upgrade-charm b/hooks/upgrade_charm.py
similarity index 100%
rename from hooks/upgrade-charm
rename to hooks/upgrade_charm.py
diff --git a/hooks/website-relation-joined b/hooks/website-relation-joined
new file mode 120000
index 0000000..e289c51
--- /dev/null
+++ b/hooks/website-relation-joined
@@ -0,0 +1 @@
+website_relation_joined.py
\ No newline at end of file
diff --git a/hooks/website-relation-joined b/hooks/website_relation_joined.py
similarity index 96%
rename from hooks/website-relation-joined
rename to hooks/website_relation_joined.py
index dc3fec0..984ae80 100755
--- a/hooks/website-relation-joined
+++ b/hooks/website_relation_joined.py
@@ -23,6 +23,7 @@ from charmhelpers.core.hookenv import (
     relation_set,
 )
 
+
 def main():
     relation_data = {'hostname': common.get_local_ingress_address()}
     sslcfg = config()['ssl']
@@ -34,5 +35,5 @@ def main():
     relation_set(None, **relation_data)
 
 
-if __name__ == '__main__':
+if __name__ == '__main__':  # pragma: no cover
     main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..5c6abac
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+pynag
+jinja2
\ No newline at end of file
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
new file mode 100644
index 0000000..aa42a09
--- /dev/null
+++ b/tests/functional/conftest.py
@@ -0,0 +1,169 @@
+#!/usr/bin/python3
+
+import asyncio
+import json
+import os
+import uuid
+
+import juju
+from juju.controller import Controller
+from juju.errors import JujuError
+from juju.model import Model
+
+import pytest
+
+
+STAT_FILE = "python3 -c \"import json; import os; s=os.stat('%s'); print(json.dumps({'uid': s.st_uid, 'gid': s.st_gid, 'mode': oct(s.st_mode), 'size': s.st_size}))\""  # noqa: E501
+
+
+@pytest.yield_fixture(scope='module')
+def event_loop(request):
+    """Override the default pytest event loop to allow for broaded scopedv fixtures."""
+    loop = asyncio.get_event_loop_policy().new_event_loop()
+    asyncio.set_event_loop(loop)
+    loop.set_debug(True)
+    yield loop
+    loop.close()
+    asyncio.set_event_loop(None)
+
+
+@pytest.fixture(scope='module')
+async def controller():
+    """Connect to the current controller."""
+    controller = Controller()
+    await controller.connect_current()
+    yield controller
+    await controller.disconnect()
+
+
+@pytest.fixture(scope='module')
+async def model(controller):
+    """Create a model that lives only for the duration of the test."""
+    model_name = "functest-{}".format(uuid.uuid4())
+    model = await controller.add_model(model_name)
+    yield model
+    await model.disconnect()
+    if os.getenv('test_preserve_model'):
+        return
+    await controller.destroy_model(model_name)
+    while model_name in await controller.list_models():
+        await asyncio.sleep(1)
+
+
+@pytest.fixture(scope='module')
+async def current_model():
+    """Return the current model, does not create or destroy it."""
+    model = Model()
+    await model.connect_current()
+    yield model
+    await model.disconnect()
+
+
+@pytest.fixture
+async def get_app(model):
+    """Return the application requested."""
+    async def _get_app(name):
+        try:
+            return model.applications[name]
+        except KeyError:
+            raise JujuError("Cannot find application {}".format(name))
+    return _get_app
+
+
+@pytest.fixture
+async def get_unit(model):
+    """Return the requested <app_name>/<unit_number> unit."""
+    async def _get_unit(name):
+        try:
+            (app_name, unit_number) = name.split('/')
+            return model.applications[app_name].units[unit_number]
+        except (KeyError, ValueError):
+            raise JujuError("Cannot find unit {}".format(name))
+    return _get_unit
+
+
+@pytest.fixture
+async def get_entity(model, get_unit, get_app):
+    """Return a unit or an application."""
+    async def _get_entity(name):
+        try:
+            return await get_unit(name)
+        except JujuError:
+            try:
+                return await get_app(name)
+            except JujuError:
+                raise JujuError("Cannot find entity {}".format(name))
+    return _get_entity
+
+
+@pytest.fixture
+async def run_command(get_unit):
+    """
+    Run a command on a unit.
+
+    :param cmd: Command to be run
+    :param target: Unit object or unit name string
+    """
+    async def _run_command(cmd, target):
+        unit = (
+            target
+            if type(target) is juju.unit.Unit
+            else await get_unit(target)
+        )
+        action = await unit.run(cmd)
+        return action.results
+    return _run_command
+
+
+@pytest.fixture
+async def file_stat(run_command):
+    """
+    Run stat on a file.
+
+    :param path: File path
+    :param target: Unit object or unit name string
+    """
+    async def _file_stat(path, target):
+        cmd = STAT_FILE % path
+        results = await run_command(cmd, target)
+        return json.loads(results['Stdout'])
+    return _file_stat
+
+
+@pytest.fixture
+async def file_contents(run_command):
+    """
+    Return the contents of a file.
+
+    :param path: File path
+    :param target: Unit object or unit name string
+    """
+    async def _file_contents(path, target):
+        cmd = 'cat {}'.format(path)
+        results = await run_command(cmd, target)
+        return results['Stdout']
+    return _file_contents
+
+
+@pytest.fixture
+async def reconfigure_app(get_app, model):
+    """Apply a different config to the requested app."""
+    async def _reconfigure_app(cfg, target):
+        application = (
+            target
+            if type(target) is juju.application.Application
+            else await get_app(target)
+        )
+        await application.set_config(cfg)
+        await application.get_config()
+        await model.block_until(lambda: application.status == 'active')
+    return _reconfigure_app
+
+
+@pytest.fixture
+async def create_group(run_command):
+    """Create the UNIX group specified."""
+    async def _create_group(group_name, target):
+        cmd = "sudo groupadd %s" % group_name
+        await run_command(cmd, target)
+    return _create_group
diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
new file mode 100644
index 0000000..94307fc
--- /dev/null
+++ b/tests/functional/requirements.txt
@@ -0,0 +1,4 @@
+juju
+requests
+pytest
+pytest-asyncio
diff --git a/tests/functional/test_deploy.py b/tests/functional/test_deploy.py
new file mode 100644
index 0000000..c45fdb8
--- /dev/null
+++ b/tests/functional/test_deploy.py
@@ -0,0 +1,70 @@
+#!/usr/bin/python3.6
+
+import os
+
+import pytest
+
+import requests
+
+pytestmark = pytest.mark.asyncio
+
+CHARM_BUILD_DIR = os.getenv("CHARM_BUILD_DIR", "..").rstrip("/")
+
+SERIES = [
+    "trusty",
+    "xenial",
+    "bionic",
+]
+
+
+############
+# FIXTURES #
+############
+
+
+@pytest.fixture(scope='module', params=SERIES)
+def series(request):
+    """Return ubuntu version (i.e. xenial) in use in the test."""
+    return request.param
+
+
+@pytest.fixture(scope='module')
+async def deploy_app(model, series):
+    """Return application of the charm under test."""
+    app_name = "nagios-{}".format(series)
+
+    """Deploy the nagios app."""
+    nagios_app = await model.deploy(
+        os.path.join(CHARM_BUILD_DIR, 'nagios'),
+        application_name=app_name,
+        series=series,
+        config={'enable_livestatus': True}
+    )
+    await model.block_until(lambda: nagios_app.status == "active")
+    # no need to cleanup since the model will be be torn down at the end of the
+    # testing
+
+    yield nagios_app
+
+
+@pytest.fixture(scope='module')
+async def unit(deploy_app):
+    """Return the thruk_agent unit we've deployed."""
+    return deploy_app.units[0]
+
+
+#########
+# TESTS #
+#########
+
+async def test_status(deploy_app):
+    """Check that the app is in active state."""
+    assert deploy_app.status == "active"
+
+
+async def test_http(model, file_contents, unit, series):
+    """Check the thruk http interface."""
+    nagiospwd = await file_contents("/var/lib/juju/nagios.passwd", unit)
+    host_url = "http://%s/"; % unit.public_address
+    auth = 'nagiosadmin', nagiospwd
+    requests.get(host_url, auth=auth)
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
new file mode 100644
index 0000000..f6fceac
--- /dev/null
+++ b/tests/unit/conftest.py
@@ -0,0 +1,5 @@
+import os
+import sys
+
+HOOKS = os.path.join(os.path.dirname(__file__), '..', '..', 'hooks')
+sys.path.append(HOOKS)
diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
new file mode 100644
index 0000000..df4d54b
--- /dev/null
+++ b/tests/unit/requirements.txt
@@ -0,0 +1,3 @@
+pytest
+pytest-cov
+pyyaml
\ No newline at end of file
diff --git a/tests/unit/test_common.py b/tests/unit/test_common.py
new file mode 100644
index 0000000..5b3a687
--- /dev/null
+++ b/tests/unit/test_common.py
@@ -0,0 +1,5 @@
+import common
+
+
+def test_check_ip():
+    pass
\ No newline at end of file
diff --git a/tests/unit/test_monitor_relation_changed.py b/tests/unit/test_monitor_relation_changed.py
new file mode 100644
index 0000000..aeda972
--- /dev/null
+++ b/tests/unit/test_monitor_relation_changed.py
@@ -0,0 +1,4 @@
+import monitors_relation_changed
+
+def test_check_ip():
+    pass
diff --git a/tests/unit/test_website_relation_joined.py b/tests/unit/test_website_relation_joined.py
new file mode 100644
index 0000000..8a30a34
--- /dev/null
+++ b/tests/unit/test_website_relation_joined.py
@@ -0,0 +1,19 @@
+import unittest.mock as mock
+
+import pytest
+import website_relation_joined
+
+
+@mock.patch('common.get_local_ingress_address')
+@mock.patch('website_relation_joined.config')
+@mock.patch('website_relation_joined.relation_set')
+@pytest.mark.parametrize('ssl', [
+    ('only', 443),
+    ('on', 80),
+    ('off', 80)
+])
+def test_main(relation_set, config, get_local_ingress_address, ssl):
+    get_local_ingress_address.return_value = 'example.com'
+    config.return_value = {'ssl': ssl[0]}
+    website_relation_joined.main()
+    relation_set.assert_called_with(None, port=ssl[1], hostname='example.com')
\ No newline at end of file
diff --git a/tox.ini b/tox.ini
index 18da390..9c59cdd 100644
--- a/tox.ini
+++ b/tox.ini
@@ -8,32 +8,31 @@ basepython = python3
 setenv =
   PYTHONPATH = .
 
-;[testenv:unit]
-;commands =
-;  {toxworkdir}/../tests/download_nagios_plugin3.py
-;  pytest -v --ignore {toxinidir}/tests/functional \
-;	   --cov=lib \
-;	   --cov=reactive \
-;	   --cov=actions \
-;	   --cov-report=term \
-;	   --cov-report=annotate:reports/annotated \
-;	   --cov-report=html:reports/html
-;deps = -r{toxinidir}/tests/unit/requirements.txt
-;       -r{toxinidir}/requirements.txt
-;setenv = PYTHONPATH={toxinidir}/lib:{envdir}/lib/python3.8
-;
-;[testenv:functional]
-;passenv =
-;  HOME
-;  CHARM_BUILD_DIR
-;  PATH
-;  PYTEST_KEEP_MODEL
-;  PYTEST_CLOUD_NAME
-;  PYTEST_CLOUD_REGION
-;  PYTEST_MODEL
-;commands = pytest -v --ignore {toxinidir}/tests/unit
-;deps = -r{toxinidir}/tests/functional/requirements.txt
-;       -r{toxinidir}/requirements.txt
+[testenv:unit]
+commands =
+  pytest -v --ignore {toxinidir}/tests/functional \
+	   --cov=hooks \
+	   --cov-report=term \
+	   --cov-report=annotate:reports/annotated \
+	   --cov-report=html:reports/html
+deps = -r{toxinidir}/tests/unit/requirements.txt
+       -r{toxinidir}/requirements.txt
+
+
+[coverage:run]
+omit = hooks/charmhelpers/*
+
+[testenv:functional]
+passenv =
+  HOME
+  CHARM_BUILD_DIR
+  PATH
+  PYTEST_KEEP_MODEL
+  PYTEST_CLOUD_NAME
+  PYTEST_CLOUD_REGION
+  PYTEST_MODEL
+commands = pytest -v --ignore {toxinidir}/tests/unit
+deps = -r{toxinidir}/tests/functional/requirements.txt
 
 [testenv:lint]
 commands = flake8

Follow ups