server/tests-py: Add a --hge-bin argument to the Pytest runner.

This argument allows the user to specify how to run HGE, rather than starting it beforehand. The runner will start a new instance of HGE for each test class.

This does not provide isolation, as the database is still re-used, but it helps us get closer.

You can try it yourself by executing:

```
$ cabal build graphql-engine:exe:graphql-engine
$ ./server/tests-py/run-new.sh
```

This doesn't affect CI at all.

I also fixed a few warnings flagged by Pylance.

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/5881
GitOrigin-RevId: ea6f0fd631a2c278b2c6b50e9dbdd9d804ebc9d4
This commit is contained in:
Samir Talwar 2022-09-15 14:30:01 +02:00 committed by hasura-bot
parent ab71adc3a0
commit 1a5aaae9cf
5 changed files with 173 additions and 36 deletions

View File

@ -7,11 +7,18 @@ import sys
import threading
import time
from context import HGECtx, HGECtxError, HGECtxGQLServer, ActionsWebhookServer, EvtsWebhookServer, GQLWsClient, PytestConf, GraphQLWSClient
from context import HGECtx, HGECtxGQLServer, ActionsWebhookServer, EvtsWebhookServer, GQLWsClient, PytestConf, GraphQLWSClient
import fixtures.hge
import graphql_server
import ports
def pytest_addoption(parser):
parser.addoption(
"--hge-bin",
metavar="HGE_BIN",
required=False,
help="Hasura GraphQL Engine binary executable",
)
parser.addoption(
"--hge-urls",
metavar="HGE_URLS",
@ -229,13 +236,13 @@ This option may result in test failures if the schema has to change between the
#By default,
#1) Set default parallelism to one
#2) Set test grouping to by filename (--dist=loadfile)
#1) Set test grouping to by class (--dist=loadfile)
#2) Set default parallelism to one
def pytest_cmdline_preparse(config, args):
worker = os.environ.get('PYTEST_XDIST_WORKER')
if 'xdist' in sys.modules and not worker: # pytest-xdist plugin
num = 1
args[:] = ["-n" + str(num),"--dist=loadfile"] + args
args[:] = ['--dist=loadfile', f'-n{num}'] + args
def pytest_configure(config):
# Pytest has removed the global pytest.config
@ -244,15 +251,18 @@ def pytest_configure(config):
if is_help_option_present(config):
return
if is_master(config):
if not config.getoption('--hge-urls'):
print("hge-urls should be specified")
assert not config.getoption('--exitfirst'), 'The "--exitfirst"/"-x" option does not work with xdist.\nSee: https://github.com/pytest-dev/pytest-xdist/issues/54'
if not (config.getoption('--hge-bin') or config.getoption('--hge-urls')):
print("either --hge-bin or --hge-urls should be specified")
if config.getoption('--hge-bin') and config.getoption('--hge-urls'):
print("only one of --hge-bin or --hge-urls should be specified")
if not config.getoption('--pg-urls'):
print("pg-urls should be specified")
config.hge_url_list = config.getoption('--hge-urls')
config.pg_url_list = config.getoption('--pg-urls')
if config.getoption('-n', default=None):
xdist_threads = config.getoption('-n')
assert xdist_threads <= len(config.hge_url_list), "Not enough hge_urls specified, Required " + str(xdist_threads) + ", got " + str(len(config.hge_url_list))
assert config.getoption('--hge-bin') or xdist_threads <= len(config.hge_url_list), "Not enough hge_urls specified, Required " + str(xdist_threads) + ", got " + str(len(config.hge_url_list))
assert xdist_threads <= len(config.pg_url_list), "Not enough pg_urls specified, Required " + str(xdist_threads) + ", got " + str(len(config.pg_url_list))
@pytest.hookimpl()
@ -295,8 +305,8 @@ def pytest_report_collectionfinish(config, startdir, items):
def pytest_configure_node(node):
if is_help_option_present(node.config):
return
# Pytest has removed the global pytest.config
node.workerinput["hge-url"] = node.config.hge_url_list.pop()
if not node.config.getoption('--hge-bin'):
node.workerinput["hge-url"] = node.config.hge_url_list.pop()
node.workerinput["pg-url"] = node.config.pg_url_list.pop()
def run_on_current_backend(request: pytest.FixtureRequest):
@ -324,40 +334,48 @@ def per_backend_test_function(request: pytest.FixtureRequest):
return per_backend_tests_fixture(request)
@pytest.fixture(scope='class')
def postgis(hge_ctx):
with sqlalchemy.create_engine(hge_ctx.pg_url).connect() as connection:
def pg_url(request) -> str:
return request.config.workerinput["pg-url"]
@pytest.fixture(scope='class')
def hge_url(request, hge_server) -> str:
if hge_server:
return hge_server
else:
return request.config.workerinput["hge-url"]
@pytest.fixture(scope='class')
def postgis(pg_url):
with sqlalchemy.create_engine(pg_url).connect() as connection:
connection.execute('CREATE EXTENSION IF NOT EXISTS postgis')
connection.execute('CREATE EXTENSION IF NOT EXISTS postgis_topology')
postgis_version = connection.execute('SELECT PostGIS_lib_version() as postgis_version').fetchone()['postgis_version']
result = connection.execute('SELECT PostGIS_lib_version() as postgis_version').fetchone()
if not result:
raise Exception('Could not detect the PostGIS version.')
postgis_version: str = result['postgis_version']
if re.match('^3\\.', postgis_version):
connection.execute('CREATE EXTENSION IF NOT EXISTS postgis_raster')
@pytest.fixture(scope='class')
def hge_ctx(request):
config = request.config
print("create hge_ctx")
if is_master(config):
hge_url = config.hge_url_list[0]
else:
hge_url = config.workerinput["hge-url"]
def hge_port():
return fixtures.hge.hge_port()
if is_master(config):
pg_url = config.pg_url_list[0]
else:
pg_url = config.workerinput["pg-url"]
@pytest.fixture(scope='class')
def hge_server(
request: pytest.FixtureRequest,
hge_port: int,
pg_url: str,
):
return fixtures.hge.hge_server(request, hge_port, pg_url)
@pytest.fixture(scope='class')
def hge_ctx(request, hge_url, pg_url):
hge_ctx = HGECtx(hge_url, pg_url, request.config)
yield hge_ctx
try:
hge_ctx = HGECtx(hge_url, pg_url, config)
except HGECtxError as e:
assert False, "Error from hge_ctx: " + str(e)
# TODO this breaks things (https://github.com/pytest-dev/pytest-xdist/issues/86)
# so at least make sure the real error gets printed (above)
pytest.exit(str(e))
yield hge_ctx # provide the fixture value
print("teardown hge_ctx")
hge_ctx.teardown()
# TODO why do we sleep here?
time.sleep(1)
time.sleep(1) # TODO why do we sleep here?
@pytest.fixture(scope='class')
def evts_webhook(request):

View File

@ -58,7 +58,7 @@ services:
postgres:
image: cimg/postgres:14.4-postgis@sha256:492a389895568e2f89a03c0c45c19350888611001123514623551a014e83a625
expose:
ports:
- 5432
environment:
POSTGRES_PASSWORD: "hasura"

View File

@ -0,0 +1,71 @@
import os
import pytest
import subprocess
import threading
from typing import Optional
import ports
# These are the names of the environment variables that should be passed through to the HGE binary.
# Other variables are ignored.
_USED_ENV_VARS = set([
'PATH', # required for basically anything to work
'HASURA_GRAPHQL_PG_SOURCE_URL_1',
'HASURA_GRAPHQL_PG_SOURCE_URL_2',
'EVENT_WEBHOOK_HEADER',
'EVENT_WEBHOOK_HANDLER',
'ACTION_WEBHOOK_HANDLER',
'SCHEDULED_TRIGGERS_WEBHOOK_DOMAIN',
'REMOTE_SCHEMAS_WEBHOOK_DOMAIN',
'GRAPHQL_SERVICE_HANDLER',
'GRAPHQL_SERVICE_1',
'GRAPHQL_SERVICE_2',
'GRAPHQL_SERVICE_3',
])
def hge_port() -> int:
return ports.find_free_port()
def hge_server(
request: pytest.FixtureRequest,
hge_port: int,
pg_url: str,
) -> Optional[str]:
hge_url = f'http://localhost:{hge_port}'
hge_bin: str = request.config.getoption('--hge-bin') # type: ignore
if not hge_bin:
return None
hge_env = {name: value for name, value in os.environ.items() if name in _USED_ENV_VARS}
print(f'Starting GraphQL Engine on {hge_url}...')
hge_process = subprocess.Popen(
args = [
hge_bin,
'--database-url', pg_url,
'serve',
'--server-port', str(hge_port),
'--stringify-numeric-types',
],
env = hge_env,
)
def stop():
if hge_process.poll() is None:
print(f'Stopping GraphQL Engine on {hge_url}...')
hge_process.terminate()
try:
hge_process.wait(timeout = 5)
print(f'GraphQL Engine on {hge_url} has stopped.')
except subprocess.TimeoutExpired:
print(f'Given up waiting; killing GraphQL Engine...')
hge_process.kill()
hge_process.wait()
print(f'GraphQL Engine has been successfully killed.')
else:
print(f'GraphQL Engine on {hge_url} has already stopped.')
# Stop in the background so we don't hold up other tests.
request.addfinalizer(lambda: threading.Thread(target = stop).start())
ports.wait_for_port(hge_port, timeout = 30)
return hge_url

46
server/tests-py/run-new.sh Executable file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# This allows a developer to easily run the Python integration tests using the
# `--hge-bin` flag.
#
# The Pytest runner will start a new HGE instance for each test class, with the
# default arguments and the environment variables provided below.
#
# This is a work in progress.
set -e
set -u
set -o pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
(
cd ../..
cabal build graphql-engine:exe:graphql-engine
make server/tests-py/.hasura-dev-python-venv
)
# shellcheck disable=SC1091
source .hasura-dev-python-venv/bin/activate
docker compose rm -svf postgres
docker compose up -d postgres
HASURA_GRAPHQL_PG_SOURCE_URL_1="postgresql://postgres:hasura@localhost:$(docker compose port --index 1 postgres 5432 | sd '.*:' '')/postgres"
HASURA_GRAPHQL_PG_SOURCE_URL_2="postgresql://postgres:hasura@localhost:$(docker compose port --index 2 postgres 5432 | sd '.*:' '')/postgres"
export HASURA_GRAPHQL_PG_SOURCE_URL_1 HASURA_GRAPHQL_PG_SOURCE_URL_2
export EVENT_WEBHOOK_HEADER='MyEnvValue'
export EVENT_WEBHOOK_HANDLER='http://localhost:5592'
export ACTION_WEBHOOK_HANDLER='http://localhost:5593'
export SCHEDULED_TRIGGERS_WEBHOOK_DOMAIN='http://localhost:5594'
export REMOTE_SCHEMAS_WEBHOOK_DOMAIN='http://localhost:5000'
export GRAPHQL_SERVICE_HANDLER='http://localhost:4001'
export GRAPHQL_SERVICE_1='http://localhost:4020'
export GRAPHQL_SERVICE_2='http://localhost:4021'
export GRAPHQL_SERVICE_3='http://localhost:4022'
pytest \
--hge-bin="$(cabal list-bin graphql-engine:exe:graphql-engine)" \
--pg-urls "$HASURA_GRAPHQL_PG_SOURCE_URL_1" "$HASURA_GRAPHQL_PG_SOURCE_URL_2" \
"$@"

View File

@ -24,6 +24,8 @@ class TestConfigAPI():
jwt_conf = hge_ctx.hge_jwt_conf
if jwt_conf is not None:
jwt_conf_dict = json.loads(hge_ctx.hge_jwt_conf)
else:
jwt_conf_dict = None
headers = { 'x-hasura-role': 'admin' }
if admin_secret is not None:
@ -38,7 +40,7 @@ class TestConfigAPI():
assert body['is_auth_hook_set'] == (auth_hook is not None)
assert body['is_jwt_set'] == (jwt_conf is not None)
if jwt_conf is not None:
if jwt_conf_dict:
claims_format = "json"
if 'claims_namespace_path' in jwt_conf_dict:
assert body['jwt']['claims_namespace_path'] == jwt_conf_dict['claims_namespace_path']