From 1907a90783165d13a53f600a82cd00dbb75a10ed Mon Sep 17 00:00:00 2001 From: Egor Tensin Date: Mon, 1 May 2023 17:40:33 +0200 Subject: add a couple of the most basic tests using Pytest I'm super-unsure about this; I don't really like all the magic, but we'll see, I guess? --- test/CMakeLists.txt | 11 +++++ test/__init__.py | 0 test/conftest.py | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++ test/lib/__init__.py | 0 test/lib/process.py | 104 +++++++++++++++++++++++++++++++++++++++++ test/pytest.ini | 5 ++ test/test_basic.py | 16 +++++++ 7 files changed, 264 insertions(+) create mode 100644 test/CMakeLists.txt create mode 100644 test/__init__.py create mode 100644 test/conftest.py create mode 100644 test/lib/__init__.py create mode 100644 test/lib/process.py create mode 100644 test/pytest.ini create mode 100644 test/test_basic.py (limited to 'test') diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..5d16f4e --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,11 @@ +find_package(Python3 REQUIRED COMPONENTS Interpreter) + +add_test( + NAME integration_tests + COMMAND Python3::Interpreter -m pytest + "${CMAKE_CURRENT_SOURCE_DIR}" + --server-binary "$" + --worker-binary "$" + --client-binary "$" + --project-version "${PROJECT_VERSION}" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..5f70bd5 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,128 @@ +# Copyright (c) 2023 Egor Tensin +# This file is part of the "cimple" project. +# For details, see https://github.com/egor-tensin/cimple. +# Distributed under the MIT License. + +import logging +import os +import random + +from pytest import fixture + +from .lib.process import run, run_async + + +class CmdLineOption: + def __init__(self, codename, help_string): + self.codename = codename + self.help_string = help_string + + @property + def cmd_line(self): + return f"--{self.codename.replace('_', '-')}" + + +class CmdLineBinary(CmdLineOption): + def __init__(self, name): + self.name = name + super().__init__(self.get_code_name(), self.get_help_string()) + + def get_code_name(self): + return f'{self.name}_binary' + + @property + def basename(self): + return f'cimple-{self.name}' + + def get_help_string(self): + return f'{self.basename} binary path' + + +CMD_LINE_BINARIES = [CmdLineBinary(name) for name in ('server', 'worker', 'client')] + + +class CmdLineVersion(CmdLineOption): + def __init__(self): + super().__init__('project_version', 'project version') + + +CMD_LINE_VERSION = CmdLineVersion() +CMD_LINE_OPTIONS = CMD_LINE_BINARIES + [CMD_LINE_VERSION] + + +def pytest_addoption(parser): + for opt in CMD_LINE_OPTIONS: + parser.addoption(opt.cmd_line, required=True, help=opt.help_string) + + +def pytest_generate_tests(metafunc): + for opt in CMD_LINE_OPTIONS: + if opt.codename in metafunc.fixturenames: + metafunc.parametrize(opt.codename, metafunc.config.getoption(opt.codename)) + + +@fixture(scope='session') +def rng(): + random.seed() + + +class Paths: + def __init__(self, pytestconfig): + for binary in CMD_LINE_BINARIES: + path = pytestconfig.getoption(binary.codename) + logging.info('%s path: %s', binary.basename, path) + setattr(self, binary.codename, path) + + +@fixture +def paths(pytestconfig): + return Paths(pytestconfig) + + +@fixture +def version(pytestconfig): + return pytestconfig.getoption(CMD_LINE_VERSION.codename) + + +@fixture +def server_port(rng): + return str(random.randint(2000, 50000)) + + +@fixture +def sqlite_path(tmp_path): + return os.path.join(tmp_path, 'cimple.sqlite') + + +@fixture +def server(paths, server_port, sqlite_path): + with run_async(paths.server_binary, '--port', server_port, '--sqlite', sqlite_path) as server: + yield + assert server.returncode == 0 + + +@fixture +def workers(paths, server_port): + args = [paths.worker_binary, '--host', '127.0.0.1', '--port', server_port] + with run_async(*args) as worker1, run_async(*args) as worker2: + yield + assert worker1.returncode == 0 + assert worker2.returncode == 0 + + +@fixture +def server_and_workers(server, workers): + yield + + +class Client: + def __init__(self, binary): + self.binary = binary + + def run(self, *args): + return run(self.binary, *args) + + +@fixture +def client(paths): + return Client(paths.client_binary) diff --git a/test/lib/__init__.py b/test/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/lib/process.py b/test/lib/process.py new file mode 100644 index 0000000..f757167 --- /dev/null +++ b/test/lib/process.py @@ -0,0 +1,104 @@ +# Copyright (c) 2023 Egor Tensin +# This file is part of the "cimple" project. +# For details, see https://github.com/egor-tensin/cimple. +# Distributed under the MIT License. + +from contextlib import contextmanager +import logging +import os +import subprocess +from threading import Thread +import time + + +_COMMON_ARGS = { + 'text': True, + 'stdin': subprocess.DEVNULL, + 'stdout': subprocess.PIPE, + 'stderr': subprocess.STDOUT, +} + + +def _canonicalize_process_args(binary, *args): + binary = os.path.abspath(binary) + argv = list(args) + argv = [binary] + argv + return binary, argv + + +def _log_process_args(binary, argv): + if argv: + logging.info('Executing binary %s with arguments: %s', binary, ' '.join(argv[1:])) + else: + logging.info('Executing binary %s', binary) + + +class LoggingThread(Thread): + def __init__(self, process): + self.process = process + target = lambda pipe: self.consume(pipe) + super().__init__(target=target, args=[process.stdout]) + + def consume(self, pipe): + for line in iter(pipe): + logging.info('%s: %s', self.process.log_id, line) + + def __enter__(self): + self.start() + return self + + def __exit__(self, *args): + self.process.shut_down() + self.join() + + +class Process(subprocess.Popen): + def __init__(self, binary, *args): + binary, argv = _canonicalize_process_args(binary, *args) + _log_process_args(binary, argv) + + self.binary = binary + self.name = os.path.basename(binary) + + super().__init__(argv, **_COMMON_ARGS) + # TODO: figure out how to remove this. + time.sleep(1) + logging.info('Process %s launched', self.log_id) + + @property + def log_id(self): + return f'{self.pid}/{self.name}' + + def __exit__(self, *args): + try: + self.shut_down() + finally: + super().__exit__(*args) + + def shut_down(self): + ec = self.poll() + if ec is not None: + return + logging.info('Terminating process %s', self.log_id) + self.terminate() + try: + self.wait(timeout=3) + return + except subprocess.TimeoutExpired: + pass + self.kill() + self.wait(timeout=3) + + +@contextmanager +def run_async(binary, *args): + with Process(binary, *args) as process, \ + LoggingThread(process): + yield process + + +def run(binary, *args): + binary, argv = _canonicalize_process_args(binary, *args) + _log_process_args(binary, argv) + result = subprocess.run(argv, **_COMMON_ARGS) + return result.returncode, result.stdout diff --git a/test/pytest.ini b/test/pytest.ini new file mode 100644 index 0000000..cdc0831 --- /dev/null +++ b/test/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +log_format = %(asctime)s | %(levelname)s | %(message)s +log_date_format = %Y-%m-%d %H:%M:%S +log_cli_level = INFO +#log_cli = 1 diff --git a/test/test_basic.py b/test/test_basic.py new file mode 100644 index 0000000..b072384 --- /dev/null +++ b/test/test_basic.py @@ -0,0 +1,16 @@ +# Copyright (c) 2023 Egor Tensin +# This file is part of the "cimple" project. +# For details, see https://github.com/egor-tensin/cimple. +# Distributed under the MIT License. + +import pytest + + +def test_server_and_workers_run(server_and_workers): + pass + + +def test_client_version(client, version): + ec, output = client.run('--version') + assert ec == 0 + assert output.endswith(version + '\n') -- cgit v1.2.3