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? --- .dockerignore | 1 + .gitignore | 2 + CMakeLists.txt | 3 ++ Dockerfile | 5 +- Makefile | 4 ++ README.md | 6 +++ 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 +++++++ 13 files changed, 283 insertions(+), 2 deletions(-) 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 diff --git a/.dockerignore b/.dockerignore index 01dd835..d6441d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ !/LICENSE.txt !/Makefile !/src/** +!/test/** diff --git a/.gitignore b/.gitignore index 0e03e15..3c2febd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /.build/ + +*.pyc diff --git a/CMakeLists.txt b/CMakeLists.txt index c82ef24..848890d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,9 @@ cmake_minimum_required(VERSION 3.3) # for include-what-you-use project(cimple VERSION 0.0.1 LANGUAGES C) +enable_testing() + add_subdirectory(src) +add_subdirectory(test) install(FILES LICENSE.txt DESTINATION "share/doc/${PROJECT_NAME}") diff --git a/Dockerfile b/Dockerfile index 47312bf..e0eb0e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG install_dir="/app/install" FROM base AS builder -RUN build_deps='bash bsd-compat-headers build-base clang cmake libgit2-dev python3 sqlite-dev' && \ +RUN build_deps='bash bsd-compat-headers build-base clang cmake libgit2-dev py3-pytest sqlite-dev' && \ apk add -q --no-cache $build_deps ARG C_COMPILER=clang @@ -21,7 +21,8 @@ RUN cd -- "$src_dir" && \ "C_COMPILER=$C_COMPILER" \ "BUILD_TYPE=$BUILD_TYPE" \ "DEFAULT_HOST=$DEFAULT_HOST" \ - "INSTALL_PREFIX=$install_dir" + "INSTALL_PREFIX=$install_dir" && \ + make test BUILD_TYPE="$BUILD_TYPE" FROM base diff --git a/Makefile b/Makefile index 5ae034d..450e57b 100644 --- a/Makefile +++ b/Makefile @@ -58,3 +58,7 @@ build: .PHONY: install install: build cmake --install '$(call escape,$(cmake_dir))' + +.PHONY: test +test: + cd -- '$(call escape,$(cmake_dir))' && ctest -C '$(call escape,$(BUILD_TYPE))' --verbose diff --git a/README.md b/README.md index 0e585bd..8052287 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ directory: make build +### Testing + +After building, you can run the "test suite" (depends on Pytest). + + make test + ### Code style Set up the git pre-commit hook by running `./scripts/setup-hooks.sh`. 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