Skip to content

Commit 597afe0

Browse files
committed
test: use pytest-xprocess to manage test servers
Remove our bespoke test server management, use pytest-xprocess instead. Ensures that pytest doesn't hang if a test fails, and that the tests run correctly in VS Code (maybe also on Windows, still to be determined). Also fixes a couple of issues identified by pylint.
1 parent 56e672e commit 597afe0

File tree

13 files changed

+309
-283
lines changed

13 files changed

+309
-283
lines changed

_appmap/metadata.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Shared metadata gathering"""
22

33
import platform
4-
import re
54
from functools import lru_cache
65

76
from . import utils

_appmap/test/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import pytest
2+
3+
# Make sure assertions in web_framework get rewritten (e.g. to show
4+
# diffs in generated appmaps)
5+
pytest.register_assert_rewrite("_appmap.test.web_framework")

_appmap/test/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import importlib
2+
import os
23
import sys
34
from distutils.dir_util import copy_tree
45
from functools import partialmethod
@@ -8,6 +9,7 @@
89

910
import _appmap
1011
import appmap
12+
from _appmap.test.web_framework import _TestRecordRequests
1113
from appmap import generation
1214

1315
from .. import utils
@@ -128,3 +130,17 @@ def _generate(check_fn, method_name):
128130
return generation.dump(rec)
129131

130132
return _generate
133+
134+
135+
@pytest.fixture(name="server_base")
136+
def server_base_fixture(request):
137+
marker = request.node.get_closest_marker("server")
138+
debug = marker.kwargs.get("debug", False)
139+
server_env = os.environ.copy()
140+
server_env.update(marker.kwargs.get("env", {}))
141+
if "PYTHONPATH" in server_env:
142+
server_env["PYTHONPATH"] = f"{server_env['PYTHONPATH']}:./init"
143+
else:
144+
server_env["PYTHONPATH"] = "./init"
145+
146+
return (_TestRecordRequests.server_host, _TestRecordRequests.server_port, debug, server_env)

_appmap/test/helpers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,14 @@ class DictIncluding(dict):
1515

1616
def __eq__(self, other):
1717
return other.items() >= self.items()
18+
19+
20+
class HeadersIncluding(dict):
21+
"""Like DictIncluding, but key comparison is case-insensitive."""
22+
23+
def __eq__(self, other):
24+
for k in self.keys():
25+
v = other.get(k, other.get(k.lower(), None))
26+
if v is None:
27+
return False
28+
return True

_appmap/test/test_django.py

Lines changed: 64 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33

44
import json
55
import os
6+
import socket
67
import sys
78
from pathlib import Path
8-
from threading import Thread
9+
from types import SimpleNamespace as NS
910

1011
import django
1112
import django.core.handlers.exception
@@ -15,30 +16,47 @@
1516
import pytest
1617
from django.template.loader import render_to_string
1718
from django.test.client import MULTIPART_CONTENT
19+
from xprocess import ProcessStarter
1820

1921
import appmap
2022
import appmap.django # noqa: F401
2123
from _appmap.metadata import Metadata
2224

2325
from ..test.helpers import DictIncluding
24-
25-
# Make sure assertions in web_framework get rewritten (e.g. to show
26-
# diffs in generated appmaps)
27-
pytest.register_assert_rewrite("_appmap.test.web_framework")
28-
29-
# pylint: disable=unused-import,wrong-import-position
30-
from .web_framework import TestRemoteRecording # pyright:ignore
31-
from .web_framework import TestRequestCapture # pyright: ignore
32-
from .web_framework import _TestRecordRequests, exec_cmd, wait_until_port_is
33-
34-
# pylint: enable=unused-import
26+
from .web_framework import (
27+
_TestFormCapture,
28+
_TestFormData,
29+
_TestRecordRequests,
30+
_TestRemoteRecording,
31+
_TestRequestCapture,
32+
)
3533

3634
sys.path += [str(Path(__file__).parent / "data" / "django")]
3735

3836
# Import app just for the side-effects. It must happen after sys.path has been modified.
3937
import djangoapp # pyright: ignore pylint: disable=import-error, unused-import,wrong-import-order,wrong-import-position
4038

4139

40+
class TestFormCapture(_TestFormCapture):
41+
pass
42+
43+
44+
class TestFormTest(_TestFormData):
45+
pass
46+
47+
48+
class TestRecordRequests(_TestRecordRequests):
49+
pass
50+
51+
52+
class TestRemoteRecording(_TestRemoteRecording):
53+
pass
54+
55+
56+
class TestRequestCapture(_TestRequestCapture):
57+
pass
58+
59+
4260
@pytest.mark.django_db
4361
@pytest.mark.appmap_enabled(appmap_enabled=False)
4462
def test_sql_capture(events):
@@ -200,55 +218,37 @@ def test_disabled(self, pytester, monkeypatch):
200218
assert not (pytester.path / "tmp").exists()
201219

202220

203-
class TestRecordRequestsDjango(_TestRecordRequests):
204-
def server_start_thread(self, debug=True):
205-
# Use appmap from our working copy, not the module installed by virtualenv. Add the init
206-
# directory so the sitecustomize.py file it contains will be loaded on startup. This
207-
# simulates a real installation.
208-
settings = "settings_dev" if debug else "settings"
209-
exec_cmd(
210-
"""
211-
export PYTHONPATH="$PWD"
212-
213-
cd _appmap/test/data/django/
214-
PYTHONPATH="$PYTHONPATH:$PWD/init"
215-
"""
216-
+ f" APPMAP_OUTPUT_DIR=/tmp DJANGO_SETTINGS_MODULE=djangoapp.{settings}"
217-
+ " python manage.py runserver"
218-
+ f" 127.0.0.1:{_TestRecordRequests.server_port}"
219-
)
220-
221-
def server_start(self, debug=True):
222-
def start_with_debug():
223-
self.server_start_thread(debug)
224-
225-
# start as background thread so running the tests can continue
226-
thread = Thread(target=start_with_debug)
227-
thread.start()
228-
wait_until_port_is("127.0.0.1", _TestRecordRequests.server_port, "open")
229-
230-
def server_stop(self):
231-
exec_cmd(
232-
"ps -ef"
233-
+ "| grep -i 'manage.py runserver'"
234-
+ "| grep -v grep"
235-
+ "| awk '{ print $2 }'"
236-
+ "| xargs kill -9"
237-
)
238-
wait_until_port_is("127.0.0.1", _TestRecordRequests.server_port, "closed")
239-
240-
def test_record_request_appmap_enabled_requests_enabled_no_remote(self):
241-
self.server_stop() # ensure it's not running
242-
self.server_start()
243-
self.record_request(False)
244-
self.server_stop()
245-
246-
def test_record_request_appmap_enabled_requests_enabled_and_remote(self):
247-
self.server_stop() # ensure it's not running
248-
self.server_start()
249-
self.record_request(True)
250-
self.server_stop()
251-
252-
# it's not possible to test for
253-
# appmap_not_enabled_requests_enabled_and_remote because when
254-
# APPMAP=false the routes for remote recording are disabled.
221+
@pytest.fixture(name="server")
222+
def django_server(xprocess, server_base):
223+
host, port, debug, server_env = server_base
224+
settings = "settings_dev" if debug else "settings"
225+
226+
class Starter(ProcessStarter):
227+
def startup_check(self):
228+
try:
229+
s = socket.socket()
230+
s.connect((host, port))
231+
return True
232+
except ConnectionRefusedError:
233+
pass
234+
return False
235+
236+
pattern = f"server at http://{host}:{port}"
237+
args = [
238+
"bash",
239+
"-ec",
240+
f"cd {Path(__file__).parent / 'data'/ 'django'};"
241+
+ f" {sys.executable} manage.py runserver"
242+
+ f" {host}:{port}",
243+
]
244+
env = {
245+
"DJANGO_SETTINGS_MODULE": f"djangoapp.{settings}",
246+
"PYTHONPATH": "./init",
247+
"PYTHONUNBUFFERED": "1",
248+
"APPMAP_OUTPUT_DIR": "/tmp",
249+
**server_env,
250+
}
251+
252+
xprocess.ensure("myserver", Starter)
253+
yield NS(debug=debug, url=f"http://{host}:{port}")
254+
xprocess.getinfo("myserver").terminate()

_appmap/test/test_flask.py

Lines changed: 60 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,40 @@
1111

1212
import flask
1313
import pytest
14+
from xprocess import ProcessStarter
1415

1516
from _appmap.env import Env
1617
from _appmap.metadata import Metadata
1718
from appmap.flask import AppmapFlask
1819

1920
from ..test.helpers import DictIncluding
21+
from .web_framework import (
22+
_TestFormCapture,
23+
_TestFormData,
24+
_TestRecordRequests,
25+
_TestRemoteRecording,
26+
_TestRequestCapture,
27+
)
2028

21-
# Make sure assertions in web_framework get rewritten (e.g. to show
22-
# diffs in generated appmaps)
23-
pytest.register_assert_rewrite("_appmap.test.web_framework")
2429

25-
# pylint: disable=unused-import,wrong-import-position
26-
from .web_framework import TestRemoteRecording # pyright:ignore
27-
from .web_framework import TestRequestCapture # pyright: ignore
28-
from .web_framework import _TestRecordRequests, exec_cmd, wait_until_port_is
30+
class TestFormCapture(_TestFormCapture):
31+
pass
2932

30-
# pylint: enable=unused-import
33+
34+
class TestFormTest(_TestFormData):
35+
pass
36+
37+
38+
class TestRecordRequests(_TestRecordRequests):
39+
pass
40+
41+
42+
class TestRemoteRecording(_TestRemoteRecording):
43+
pass
44+
45+
46+
class TestRequestCapture(_TestRequestCapture):
47+
pass
3148

3249

3350
@pytest.fixture(name="app")
@@ -44,7 +61,7 @@ def flask_app(data_dir, monkeypatch):
4461

4562
# Add the AppmapFlask extension to the app. This now happens automatically when a Flask app is
4663
# started from the command line, but must be done manually otherwise.
47-
AppmapFlask().init_app(flaskapp.app)
64+
AppmapFlask(flaskapp.app).init_app()
4865

4966
return flaskapp.app
5067

@@ -93,58 +110,41 @@ def test_template(app, events):
93110
)
94111

95112

96-
class TestRecordRequestsFlask(_TestRecordRequests):
97-
def server_start_thread(self, debug=True):
98-
# Use appmap from our working copy, not the module installed by virtualenv. Add the init
99-
# directory so the sitecustomize.py file it contains will be loaded on startup. This
100-
# simulates a real installation.
101-
flask_debug = "FLASK_DEBUG=1" if debug else ""
102-
103-
exec_cmd(
104-
"""
105-
export PYTHONPATH="$PWD"
106-
107-
cd _appmap/test/data/flask/
108-
PYTHONPATH="$PYTHONPATH:$PWD/init"
109-
"""
110-
+ f" APPMAP_OUTPUT_DIR=/tmp {flask_debug} FLASK_APP=flaskapp.py flask run -p "
111-
+ str(_TestRecordRequests.server_port)
112-
)
113+
@pytest.fixture(name="server")
114+
def flask_server(xprocess, server_base):
115+
host, port, debug, server_env = server_base
116+
flask_debug = "1" if debug else "0"
117+
118+
class Starter(ProcessStarter):
119+
def startup_check(self):
120+
try:
121+
s = socket.socket()
122+
s.connect((host, port))
123+
return True
124+
except ConnectionRefusedError:
125+
pass
126+
return False
127+
128+
pattern = f"Running on http://{host}:{port}"
129+
args = [
130+
"bash",
131+
"-ec",
132+
f"cd {Path(__file__).parent / 'data'/ 'flask'};"
133+
+ f" {sys.executable} -m flask run"
134+
+ f" -p {port}",
135+
]
136+
print(args)
137+
env = {
138+
"FLASK_APP": "flaskapp.py",
139+
"FLASK_DEBUG": flask_debug,
140+
"PYTHONUNBUFFERED": "1",
141+
"APPMAP_OUTPUT_DIR": "/tmp",
142+
**server_env,
143+
}
113144

114-
def server_start(self, debug=True):
115-
# start as background thread so running the tests can continue
116-
def start_with_debug():
117-
self.server_start_thread(debug)
118-
119-
thread = Thread(target=start_with_debug)
120-
thread.start()
121-
wait_until_port_is("127.0.0.1", _TestRecordRequests.server_port, "open")
122-
123-
def server_stop(self):
124-
exec_cmd(
125-
"ps -ef"
126-
+ "| grep -i 'flask run'"
127-
+ "| grep -v grep"
128-
+ "| awk '{ print $2 }'"
129-
+ "| xargs kill -9"
130-
)
131-
wait_until_port_is("127.0.0.1", _TestRecordRequests.server_port, "closed")
132-
133-
def test_record_request_appmap_enabled_requests_enabled_no_remote(self):
134-
self.server_stop() # ensure it's not running
135-
self.server_start()
136-
self.record_request(False)
137-
self.server_stop()
138-
139-
def test_record_request_appmap_enabled_requests_enabled_and_remote(self):
140-
self.server_stop() # ensure it's not running
141-
self.server_start()
142-
self.record_request(True)
143-
self.server_stop()
144-
145-
# it's not possible to test for
146-
# appmap_not_enabled_requests_enabled_and_remote because when
147-
# APPMAP=false the routes for remote recording are disabled.
145+
xprocess.ensure("myserver", Starter)
146+
yield NS(debug=debug, url=f"http://{host}:{port}")
147+
xprocess.getinfo("myserver").terminate()
148148

149149

150150
class TestFlaskApp:

0 commit comments

Comments
 (0)