MT#16391 Initial tests using pytest

The old testsuite wasn't updated for way too long and since
ngcpcfg receives more and more features we need a decent test
coverage.  pytest seems to provide the right level of
abstraction, excellent fixtures and junit-xml reporting as
needed.

Inspired by Vincent Bernat's
https://github.com/vincentbernat/lldpd/tree/master/tests/integration

Thanks Victor Seva <vseva@sipwise.com>, Vincent Bernat <vincent@bernat.im>, Christian Hofstaedtler <christian@hofstaedtler.name> and Lukas Prokop <admin@lukas-prokop.at> for feedback, inspiration and help

Change-Id: Iffed87e8cc540169bed89c00967a03e80859179e
changes/20/7420/17
Michael Prokop 9 years ago
parent e40ec0137e
commit acfa825767

13
.gitignore vendored

@ -0,0 +1,13 @@
# cache files
__pycache__/
*.pyc
# editor temp files
*~
.*.sw?
.sw?
\#*\#
DEADJOE
# testing output
results

@ -7,6 +7,7 @@ PERL_SCRIPTS = helper/sort-yml \
sbin/ngcp-network \
sbin/ngcp-sync-constants \
sbin/ngcp-sync-grants
RESULTS ?= results
all: docs
@ -33,10 +34,12 @@ man:
clean:
rm -f docs/ngcpcfg.xml docs/ngcpcfg.epub docs/ngcpcfg.html docs/ngcpcfg.pdf
rm -f ngcpcfg.8 ngcp-network.8 ngcp-sync-constants.8 ngcp-sync-grants.8
rm -rf t/__pycache__ t/fixtures/__pycache__/ t/*.pyc
dist-clean:
dist-clean: clean
rm -f docs/ngcpcfg.html docs/ngcpcfg.pdf
rm -f docs/ngcpcfg.epub ngcpcfg.8
rm -rf results
# check for syntax errors
syntaxcheck: shellcheck perlcheck
@ -58,4 +61,7 @@ perlcheck:
done; \
echo "-> perl check done."; \
test:
mkdir -p $(RESULTS)
cd t ; py.test-3 --junit-xml=../$(RESULTS)/pytest.xml -v
# EOF

3
debian/control vendored

@ -5,6 +5,7 @@ Maintainer: Sipwise Development Team <support@sipwise.com>
Build-Depends: asciidoc,
debhelper (>= 9~),
docbook-xsl,
git,
libclone-perl,
libconfig-tiny-perl,
libdata-validate-ip-perl,
@ -23,6 +24,8 @@ Build-Depends: asciidoc,
libtemplate-perl,
libyaml-perl,
libyaml-tiny-perl,
netcat,
python3-pytest,
xsltproc
Standards-Version: 3.9.7
Homepage: http://sipwise.com/

9
debian/rules vendored

@ -18,3 +18,12 @@ override_dh_install:
test -r $(CURDIR)/usr/sbin/ngcp-network && \
sed -i -e "s/VERSION = 'UNRELEASED'/VERSION = '$(VERSION)'/" \
$(CURDIR)/usr/sbin/ngcp-network || true
override_dh_auto_test:
dh_auto_test
## this is a hack to automatically copy the pytest result via pbuilder to
## the workspace so we can automatically use it as Jenkins test result
# starting with pbuilder v0.216 it defaults to /build
if [ -d /build/ ] ; then find $(CURDIR)/results/ -name pytest.xml -exec cp {} /build/ \; || true ; fi
# previous pbuilder versions default to /tmp/buildd
if [ -d /tmp/buildd/ ] ; then find $(CURDIR)/results/ -name pytest.xml -exec cp {} /tmp/buildd/ \; || true ; fi

@ -118,8 +118,9 @@ get_branch_status() {
## important variables we depend on to operate successfully {{{
# support test suite which requires system independent configuration
if [ -r ngcpcfg-testsuite.cfg ] ; then
. ngcpcfg-testsuite.cfg
if [ -r "${NGCPCFG:-}" ] ; then
log_debug "sourcing configuration file ${NGCPCFG:-}"
. "${NGCPCFG:-}"
else
if [ -r /etc/ngcp-config/ngcpcfg.cfg ] ; then
. /etc/ngcp-config/ngcpcfg.cfg

@ -0,0 +1,36 @@
# DOCKER_NAME=ngcpcfg-jessie
FROM docker.mgm.sipwise.com/sipwise-jessie:latest
# Important! Update this no-op ENV variable when this Dockerfile
# is updated with the current date. It will force refresh of all
# of the base images and things like `apt-get update` won't be using
# old cached versions when the Dockerfile is built.
ENV REFRESHED_AT 2017-05-27
RUN apt-get update
# sourcecode test dependencies
RUN apt-get install --assume-yes libyaml-tiny-perl netcat libhash-merge-perl libtemplate-perl libyaml-perl
# misc stuff for execution/debugging/devel
RUN apt-get install --assume-yes fakeroot git lsof make strace
# test execution
RUN apt-get install --assume-yes python3-pytest python3-junitxml
RUN echo './t/testrunner' >>/root/.bash_history
WORKDIR /code/
################################################################################
# Instructions for usage
# ----------------------
# When you want to build the base image from scratch (jump to the next section if you don't want to build yourself!):
# % docker build --tag="ngcpcfg-jessie" .
# % docker run --rm -i -t -v $(pwd)/..:/code:rw ngcpcfg-jessie:latest bash
#
# Use the existing docker image:
# % docker pull docker1.mgm.sipwise.com/ngcpcfg-jessie
# % docker run --rm -i -t -v $(pwd)/..:/code:rw docker1.mgm.sipwise.com/ngcpcfg-jessie:latest bash
#
# Inside docker (the command is in history, just press UP button):
# ./t/testrunner
################################################################################

@ -0,0 +1,2 @@
import pytest
from fixtures import *

Binary file not shown.

@ -0,0 +1,17 @@
# begin section managed by ngcpcfg (do not edit this section by hand)
# new and old versions of conffiles, stored by dpkg
*.dpkg-*
# new and old versions of conffiles, stored by ucf
*.ucf-*
# old versions of files
*.old
# editor temp files
*~
.*.sw?
.sw?
\#*\#
DEADJOE
# end section managed by ngcpcfg

@ -0,0 +1,4 @@
from .commandline import *
from .fs import *
from .gitrepo import *
from .programs import *

@ -0,0 +1,2 @@
#!/bin/sh
echo spce

@ -0,0 +1,64 @@
#!/usr/bin/env python3
import pytest
import functools
import subprocess
import collections
ProcessResult = collections.namedtuple('ProcessResult',
['exitcode', 'stdout', 'stderr'])
def run(*args, env=None, timeout=None, stdin=None, expect_stdout=True,
expect_stderr=True, outencoding='utf-8', errencoding='utf-8'):
"""Execute command in `args` in a subprocess"""
# drop stdout & stderr and spare some memory, if not needed
stdout = subprocess.PIPE
if not expect_stdout:
stdout = None
stderr = subprocess.PIPE
if not expect_stderr:
stderr = None
# run command
p = subprocess.Popen(args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env)
if stdin:
p.stdin.write(stdin)
stdout, stderr = p.communicate(timeout=timeout)
# decode output
if expect_stdout and stdout:
stdoutput = stdout.decode(outencoding)
else:
stdoutput = ''
if expect_stderr and stderr:
stderror = stderr.decode(errencoding)
else:
stderror = ''
return ProcessResult(p.returncode, stdoutput, stderror)
@pytest.fixture()
def cli():
"""Run a command as subprocess.
Returns a namedtuple with members (stdout, stderr, exitcode).
:param env: a dictionary of environment variables
:param timeout: maximum number of seconds before
TimeoutError is raised
:param stdin: bytes-object to write to stdin once the
program has started
:param expect_stdout: shall stdout be provided in the result?
:param outencoding: encoding to decode stdout
:param expect_stderr: shall stderr be provided in the result?
:param errencoding: encoding to decode stderr
"""
return run

@ -0,0 +1,15 @@
#!/usr/bin/env python3
import pytest
import os.path
import contextlib
@contextlib.contextmanager
def keep_directory():
"""Restore the current working directory when finished."""
cwd = os.getcwd()
try:
yield
finally:
os.chdir(cwd)

@ -0,0 +1,193 @@
#!/usr/bin/env python3
import os
import shutil
import pytest
import tarfile
import zipfile
import tempfile
from .commandline import run
from .fs import keep_directory
BUILTIN_GIT_COMMANDS = {"add", "am", "annotate", "apply", "archive",
"bisect--helper", "blame", "branch",
"bundle", "cat-file", "check-attr",
"check-ignore", "check-mailmap",
"check-ref-format", "checkout",
"checkout-index", "cherry",
"cherry-pick", "clean", "clone",
"column", "commit", "commit-tree",
"config", "count-objects", "credential",
"describe", "diff", "diff-files",
"diff-index", "diff-tree",
"fast-export", "fetch", "fetch-pack",
"fmt-merge-msg", "for-each-ref",
"format-patch", "fsck", "fsck-objects",
"gc", "get-tar-commit-id", "grep",
"hash-object", "help", "index-pack",
"init", "init-db", "interpret-trailers",
"log", "ls-files", "ls-remote",
"ls-tree", "mailinfo", "mailsplit",
"merge", "merge-base", "merge-file",
"merge-index", "merge-ours",
"merge-recursive", "merge-subtree",
"merge-tree", "mktag", "mktree", "mv",
"name-rev", "notes", "pack-objects",
"pack-redundant", "pack-refs",
"patch-id", "prune", "prune-packed",
"pull", "push", "read-tree",
"receive-pack", "reflog", "remote",
"remote-ext", "remote-fd", "remote-ftp",
"remote-ftps", "remote-https", "repack",
"replace", "rerere", "reset",
"rev-list", "rev-parse", "revert", "rm",
"send-pack", "shortlog", "show",
"show-branch", "show-ref", "stage",
"status", "stripspace",
"submodule--helper", "symbolic-ref",
"tag", "unpack-file", "unpack-objects",
"update-index", "update-ref",
"update-server-info", "upload-archive",
"var", "verify-commit", "verify-pack",
"verify-tag", "whatchanged", "worktree",
"write-tree"}
# helpers
def create_tmp_folder():
"""Create and return a unique temporary folder"""
return tempfile.mkdtemp(prefix='ngcp-', suffix='-pytest')
def delete_tmp_folder(folder):
"""Recursively delete a folder"""
shutil.rmtree(folder)
def find_git_root(folder):
"""If `folder` contains one folder,
return this folder. Otherwise `folder`"""
children = os.listdir(folder)
if len(children) > 1:
return folder
else:
return os.path.join(folder, children[0])
# implementation
class GitCommand:
def __init__(self, repo, command):
self.repo = repo
self.command = command
def __call__(self, *args):
with keep_directory():
os.chdir(self.repo.root)
return run('git', self.command, *args)
def __repr__(self):
return "command 'git {}'".format(self.command)
class GitRepository:
"""Represents a git repository we are working with"""
def __init__(self, root, delete_fn=None):
self.root = root
self.delete_fn = delete_fn
def __getattr__(self, name):
if name not in BUILTIN_GIT_COMMANDS:
raise ValueError("git command '{}' is unknown".format(name))
return GitCommand(self, name)
def __enter__(self):
return self
def __exit__(self, *exc_details):
if self.delete_fn:
self.delete_fn()
@property
def branch(self):
"""Return current git branch"""
ex, out, err = GitCommand(self, 'branch')()
assert ex == 0
for line in out.splitlines():
if line.startswith('*'):
return line[1:].strip()
@property
def version(self):
"""Return git version in use"""
ex, out, err = GitCommand(self, '--version')()
assert ex == 0
return ' '.join(out.strip().split()[2:])
def __repr__(self):
return "GitRepository at '{}'".format(self.root)
class GitLoader:
"""Provides methods to download a git repository"""
default = 'default-git-repository.tar.gz'
def __init__(self):
self.localpath = create_tmp_folder()
@staticmethod
def extract_archive(src, dest):
"""Extract files and folder in archive `src` to path `dest`"""
suffix = None
if src.endswith('.tgz') or src.endswith('.tar.gz'):
suffix = ':gz'
elif src.endswith('.tbz2') or src.endswith('.tar.bz2'):
suffix = ':bz2'
elif src.endswith('.tar.lzma'):
suffix = ':xz'
elif src.endswith('.tar'):
suffix = ''
if suffix is not None:
ar = tarfile.open(src, 'r' + suffix)
ar.extractall(path=dest)
return dest
elif src.endswith('.zip'):
with zipfile.ZipFile(src, 'r') as fd:
fd.extractall(path=dest)
return dest
else:
raise ValueError('Archive of unknown file type: {}'.format(src))
def from_url(self, url):
"""Clone git repository from URL"""
ex, out, err = cli.run('git', 'clone', url, self.localpath)
assert ex == 0
return GitRepository(find_git_root(self.localpath),
delete_fn=self.cleanup)
def from_archive(self, archive_path):
"""Extract git repository from given archive"""
self.localpath = self.extract_archive(archive_path, self.localpath)
return GitRepository(find_git_root(self.localpath),
delete_fn=self.cleanup)
def in_folder(self, path):
"""Assume `path` already contains a git repository"""
assert os.path.exists(os.path.join(path, '.git')), \
'.git folder must exist in git repository'
return GitRepository(path)
def cleanup(self):
delete_tmp_folder(self.localpath)
def __repr__(self):
return "GitLoader for '{}'".format(self.localpath)
@pytest.fixture()
def gitrepo():
return GitLoader()

@ -0,0 +1,37 @@
# directory name where ngcpcfg is managed through git
NGCPCTL_BASE="$(pwd)/"
NGCPCTL_MAIN="${NGCPCTL_BASE}/fixtures/repos/"
NGCPCTL_CONFIG="${NGCPCTL_MAIN}/config.yml"
HOST_CONFIG="${NGCPCTL_MAIN}/config.$(hostname).yml"
LOCAL_CONFIG="${NGCPCTL_MAIN}/config.local.yml"
CONSTANTS_CONFIG="${NGCPCTL_MAIN}/constants.yml"
NETWORK_CONFIG="${NGCPCTL_MAIN}/network.yml"
RTP_INTERFACES_CONFIG="/etc/ngcp-rtpengine-daemon/interfaces.yml"
EXTRA_CONFIG_DIR="${NGCPCTL_MAIN}/config.d/"
# configuration dirs that should be managed
CONFIG_POOL='/etc /var'
# location of templates
TEMPLATE_POOL_BASE="${NGCPCTL_MAIN}/templates"
# location of service definitions
SERVICES_POOL_BASE="${NGCPCTL_MAIN}/templates"
# Backward compatibility config for upgrade mr3.4*->mr3.5*
# it can be removed when the next LTS is released:
TEMPLATE_POOL="${TEMPLATE_POOL_BASE}/etc"
SERVICES_POOL="${SERVICES_POOL_BASE}/etc"
# timestamp format for console output
TIME_FORMAT="+%F %T"
# Run-time state directory
RUN_DIR='/var/run'
# directory holding files for internal state of ngcpcfg
STATE_FILES_DIR='/var/lib/ngcpcfg/state/'
# validate configs using kwalify schema
VALIDATE_SCHEMA="false"
## END OF FILE #################################################################

@ -0,0 +1,40 @@
import pytest
import os
import subprocess
import sys
from collections import namedtuple
@pytest.fixture()
def ngcpcfgcli(tmpdir, *args):
"""Execute ``ngcpcfg``."""
def run(*args, env={}):
testenv = {
'PATH': 'fixtures/bin:/usr/bin:/bin:/usr/sbin:/sbin',
'FUNCTIONS': '../functions/',
'NGCPCFG': 'fixtures/ngcpcfg.cfg',
'SCRIPTS': '../scripts/',
'HELPER': '../helper/',
'NGCP_PORTFILE': '/tmp/ngcpcfg.port',
}
testenv.update(env)
# if we're already running under root don't execute under fakeroot,
# causing strange problems when debugging execution e.g. via strace
if os.getuid() == 0:
fakeroot = []
else:
fakeroot = ['fakeroot']
p = subprocess.Popen(fakeroot + ['../sbin/ngcpcfg'] + list(args),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=testenv)
stdout, stderr = p.communicate(timeout=30)
stdout, stderr = str(stdout), str(stderr)
result = namedtuple('ProcessResult',
['returncode', 'stdout', 'stderr'])(
p.returncode, stdout, stderr)
return result
return run

@ -0,0 +1,3 @@
---
hosts:
self:

@ -0,0 +1,2 @@
[% TAGS [- -] %]
APT::Install-Recommends "0";

@ -0,0 +1,2 @@
[pytest]
python_files=*.py

@ -0,0 +1,49 @@
#!/usr/bin/env py.test-3
import os
import pytest
import re
def test_add_file_to_default_repo(cli, gitrepo):
src = 'default-git-repository.tar.gz'
with gitrepo.from_archive(src) as git:
# configure git user
git.config('--local', 'user.email', 'me@example.com')
git.config('--local', 'user.name', 'pytest robot')
print('Using git {}'.format(git.version))
# git status
ex, out, err = git.status()
assert ex == 0
assert 'On branch' in out
# create new file
newfile = os.path.join(git.root, 'newfile')
with open(newfile, 'w') as fd:
fd.write('#!/bin/sh\necho "Hello World"\n')
# adding file to repository
assert git.branch == 'master'
git.add(newfile)
git.commit('-m', 'Adding a new file')
# checking log
ex, out, err = git.log()
assert ex == 0
assert 'Adding a new file' in out
@pytest.mark.tt_11776
def test_status_output(cli, gitrepo):
# we mock an existing repository by loading it from the default archive
with gitrepo.from_archive(gitrepo.default) as git:
# now we work with "existing" repository with path given in git.root
with gitrepo.in_folder(git.root) as git:
ex, out, err = git.status()
assert ex == 0
# git >=v2.9.3 uses "working tree",
# before it was "working directory"
regex = re.compile(r"nothing to commit, working (tree|directory)")
assert re.search(regex, out)

@ -0,0 +1,37 @@
#!/usr/bin/env py.test-3
import pytest
import re
import tempfile
@pytest.mark.cmdline
def test_ngcpcfgcfg_ok(ngcpcfgcli):
out = ngcpcfgcli()
assert 'For further usage information and options ' \
'see the ngcpcfg(8) man page' in out.stderr
@pytest.mark.cmdline
def test_ngcpcfgcfg_ko(ngcpcfgcli):
out = ngcpcfgcli(env={'NGCPCFG': '/doesnotexist'})
assert 'Error: Could not read configuration file ' \
'/etc/ngcp-config/ngcpcfg.cfg. Exiting.' in out.stderr
# NOTE - this one fails if the *main* ngcpcfg.git is not
# standing on master branch, therefore use --ignore-branch-check
# until we've a mock/fixture for it
@pytest.mark.mt_16391
def test_simple_build_template_ok(ngcpcfgcli):
tmpdir = tempfile.mkdtemp(prefix='ngcp-', suffix='-pytest-output')
out = ngcpcfgcli("build", "--ignore-branch-check",
"/etc/apt/apt.conf.d/71_no_recommended",
env={
'NGCP_PORTFILE': '/tmp/ngcpcfg.port',
'OUTPUT_DIRECTORY': tmpdir,
})
regex = re.compile(r"Generating " +
tmpdir +
r"//etc/apt/apt.conf.d/71_no_recommended: OK")
assert re.search(regex, out.stdout)

@ -0,0 +1,19 @@
#!/bin/bash
# This script is used for running the tests with proper arguments
# from within Jenkins
set -e
set -u
if [ -d /results ] ; then
# Running from Jenkins (RW)
RESULTS="/results"
cd "/code"
else
# Running locally in Docker
RESULTS="./results"
mkdir -p "${RESULTS}"
fi
RESULTS=${RESULTS} make test
Loading…
Cancel
Save