Skip to content

Commit bd0c5af

Browse files
committed
DEVEXP 757: [Python E2E] Run tests with Sync and Async clients
For the moment, the E2E tests steps have been implemented using the Sync SinchClient. As the Async SinchClient uses another HTTP library and the pagination uses a different implementation, it's necessary to run the E2E tests suite in both ways. Signed-off-by: Jessica Matsuoka <jessica.akemi.matsuoka@sinch.com>
1 parent 2bb078e commit bd0c5af

File tree

3 files changed

+111
-33
lines changed

3 files changed

+111
-33
lines changed

.github/workflows/run-tests.yml

+12-3
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,15 @@ jobs:
3636
run: |
3737
python -m pip install --upgrade pip
3838
pip install -r requirements-dev.txt
39+
3940
- name: Lint with flake8
4041
run: |
4142
flake8 sinch --count --max-complexity=10 --max-line-length=120 --statistics
43+
4244
- name: Test with Pytest
4345
run: |
4446
coverage run --source=. -m pytest
47+
4548
- name: Coverage Test Report
4649
run: |
4750
python -m coverage report --skip-empty
@@ -72,6 +75,12 @@ jobs:
7275
run: .github/scripts/wait-for-mockserver.sh
7376
shell: bash
7477

75-
- name: Run e2e tests
76-
run: behave tests/e2e/**/features
77-
78+
- name: Run e2e tests sync
79+
run: |
80+
export SINCH_CLIENT_MODE=sync
81+
behave tests/e2e/**/features
82+
83+
- name: Run e2e tests async
84+
run: |
85+
export SINCH_CLIENT_MODE=async
86+
behave tests/e2e/**/features
+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import os
2+
import logging
3+
import asyncio
4+
from sinch import SinchClient, SinchClientAsync
5+
6+
def get_logger():
7+
"""Creates and returns a logger instance for this module only."""
8+
log = logging.getLogger(__name__)
9+
log.setLevel(logging.INFO)
10+
if not log.hasHandlers():
11+
handler = logging.StreamHandler()
12+
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
13+
log.addHandler(handler)
14+
log.propagate = False
15+
16+
return log
17+
18+
logger = get_logger()
19+
20+
def before_all(context):
21+
"""
22+
Initializes the appropriate Sinch client based on the environment variable SINCH_CLIENT_MODE.
23+
If it's set to 'async', a single event loop is created for all tests.
24+
Otherwise, we use the synchronous client.
25+
"""
26+
client_mode = os.getenv('SINCH_CLIENT_MODE', 'sync')
27+
28+
logger.info(f" Running E2E tests in **{client_mode.upper()}** mode")
29+
30+
client_params = {
31+
'project_id': 'tinyfrog-jump-high-over-lilypadbasin',
32+
'key_id': 'keyId',
33+
'key_secret': 'keySecret',
34+
}
35+
if client_mode == 'async':
36+
# Create and set a single event loop for the entire test run
37+
context.loop = asyncio.new_event_loop()
38+
asyncio.set_event_loop(context.loop)
39+
context.sinch = SinchClientAsync(**client_params)
40+
else:
41+
# Sync client does not need an event loop
42+
context.sinch = SinchClient(**client_params)
43+
context.sinch.configuration.auth_origin = 'http://localhost:3011'
44+
context.sinch.configuration.numbers_origin = 'http://localhost:3013'
45+
46+
def after_all(context):
47+
"""
48+
Closes the Async event loop if it was created during the test
49+
"""
50+
if hasattr(context, 'loop'):
51+
context.loop.close()

tests/e2e/numbers/features/steps/numbers.steps.py

+48-30
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,39 @@
1+
import inspect
12
from datetime import timezone, datetime
23
from behave import given, when, then
34
from decimal import Decimal
4-
from sinch import SinchClient
55
from sinch.domains.numbers.exceptions import NumberNotFoundException
66
from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse
77
from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse
88
from sinch.domains.numbers.models.numbers import NotFoundError
99

10+
def execute_sync_or_async(context,call):
11+
"""
12+
Ensures proper execution of both synchronous and asynchronous calls.
13+
- If the call is synchronous, it executes directly.
14+
- If the call is a coroutine (async), it runs using asyncio
15+
This abstracts away execution differences, allowing test steps to be written uniformly.
16+
"""
17+
if call is None:
18+
return None
19+
if inspect.iscoroutine(call):
20+
# Reuse the single loop created in before_all
21+
return context.loop.run_until_complete(call)
22+
else:
23+
return call
1024

1125
@given('the Numbers service is available')
1226
def step_service_is_available(context):
13-
sinch = SinchClient(
14-
project_id='tinyfrog-jump-high-over-lilypadbasin',
15-
key_id='keyId',
16-
key_secret='keySecret',
17-
)
18-
sinch.configuration.auth_origin = 'http://localhost:3011'
19-
sinch.configuration.numbers_origin = 'http://localhost:3013'
20-
context.sinch = sinch
27+
"""Ensures the Sinch client is initialized"""
28+
assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized'
2129

2230
@when('I send a request to search for available phone numbers')
2331
def step_search_available_numbers(context):
2432
response = context.sinch.numbers.available.list(
2533
region_code='US',
2634
number_type='LOCAL'
2735
)
28-
context.response = response
36+
context.response = execute_sync_or_async(context, response)
2937

3038
@then('the response contains "{count}" available phone numbers')
3139
def step_check_available_numbers_count(context, count):
@@ -50,11 +58,10 @@ def step_check_number_properties(context):
5058
def step_check_number_availability(context, phone_number):
5159
try:
5260
response = context.sinch.numbers.available.check_availability(phone_number)
53-
context.response = response
61+
context.response = execute_sync_or_async(context, response)
5462
except NumberNotFoundException as e:
5563
context.error = e
5664

57-
5865
@then('the response displays the phone number "{phone_number}" details')
5966
def step_validate_number_details(context, phone_number):
6067
data = context.response
@@ -69,8 +76,7 @@ def step_check_unavailable_number(context, phone_number):
6976

7077
@when('I send a request to rent a number with some criteria')
7178
def step_rent_any_number(context):
72-
sinch_client: SinchClient = context.sinch
73-
response = sinch_client.numbers.available.rent_any(
79+
response = context.sinch.numbers.available.rent_any(
7480
region_code = 'US',
7581
type_ = 'LOCAL',
7682
capabilities = ['SMS', 'VOICE'],
@@ -86,7 +92,7 @@ def step_rent_any_number(context):
8692
'search_pattern': 'END'
8793
},
8894
)
89-
context.response = response
95+
context.response = execute_sync_or_async(context, response)
9096

9197
@then('the response contains a rented phone number')
9298
def step_validate_rented_number(context):
@@ -100,15 +106,19 @@ def step_validate_rented_number(context):
100106
assert data.money.currency_code == 'EUR'
101107
assert data.money.amount == Decimal('0.80')
102108
assert data.payment_interval_months == 1
103-
assert data.next_charge_date == datetime.fromisoformat('2024-06-06T14:42:42.022227+00:00').astimezone(tz=timezone.utc)
109+
assert data.next_charge_date == datetime.fromisoformat(
110+
'2024-06-06T14:42:42.022227+00:00'
111+
).astimezone(tz=timezone.utc)
104112
assert data.expire_at == None
105113
assert data.callback_url == ''
106114
assert data.sms_configuration.service_plan_id == ''
107115
assert data.sms_configuration.campaign_id == ''
108116
assert data.sms_configuration.scheduled_provisioning.service_plan_id == 'SpaceMonkeySquadron'
109117
assert data.sms_configuration.scheduled_provisioning.campaign_id == ''
110118
assert data.sms_configuration.scheduled_provisioning.status == 'WAITING'
111-
assert data.sms_configuration.scheduled_provisioning.last_updated_time == datetime.fromisoformat('2024-06-06T14:42:42.596223+00:00').astimezone(tz=timezone.utc)
119+
assert data.sms_configuration.scheduled_provisioning.last_updated_time == datetime.fromisoformat(
120+
'2024-06-06T14:42:42.596223+00:00'
121+
).astimezone(tz=timezone.utc)
112122
assert data.sms_configuration.scheduled_provisioning.error_codes == []
113123
assert data.voice_configuration.type == 'RTC'
114124
assert data.voice_configuration.app_id == ''
@@ -119,12 +129,13 @@ def step_validate_rented_number(context):
119129
assert data.voice_configuration.scheduled_voice_provisioning.trunk_id == ''
120130
assert data.voice_configuration.scheduled_voice_provisioning.service_id == ''
121131
assert data.voice_configuration.scheduled_voice_provisioning.status == 'WAITING'
122-
assert data.voice_configuration.scheduled_voice_provisioning.last_updated_time == datetime.fromisoformat('2024-06-06T14:42:42.604092+00:00').astimezone(tz=timezone.utc)
132+
assert data.voice_configuration.scheduled_voice_provisioning.last_updated_time == datetime.fromisoformat(
133+
'2024-06-06T14:42:42.604092+00:00'
134+
).astimezone(tz=timezone.utc)
123135

124136
@when('I send a request to rent the phone number "{phone_number}"')
125137
def step_rent_specific_number(context, phone_number):
126-
sinch_client: SinchClient = context.sinch
127-
response = sinch_client.numbers.available.activate(
138+
response = context.sinch.numbers.available.activate(
128139
phone_number = phone_number,
129140
sms_configuration= {
130141
'service_plan_id': 'SpaceMonkeySquadron',
@@ -133,7 +144,7 @@ def step_rent_specific_number(context, phone_number):
133144
'app_id': 'sunshine-rain-drop-very-beautifulday'
134145
}
135146
)
136-
context.response = response
147+
context.response = execute_sync_or_async(context, response)
137148

138149
@then('the response contains this rented phone number "{phone_number}"')
139150
def step_validate_rented_specific_number(context, phone_number):
@@ -142,9 +153,8 @@ def step_validate_rented_specific_number(context, phone_number):
142153

143154
@when('I send a request to rent the unavailable phone number "{phone_number}"')
144155
def step_rent_unavailable_number(context, phone_number):
145-
sinch_client: SinchClient = context.sinch
146156
try:
147-
response = sinch_client.numbers.available.activate(
157+
response = context.sinch.numbers.available.activate(
148158
phone_number=phone_number,
149159
sms_configuration={
150160
'service_plan_id': 'SpaceMonkeySquadron',
@@ -153,18 +163,18 @@ def step_rent_unavailable_number(context, phone_number):
153163
'app_id': 'sunshine-rain-drop-very-beautifulday'
154164
}
155165
)
156-
context.response = response
166+
context.response = execute_sync_or_async(context, response)
157167
except NumberNotFoundException as e:
158168
context.error = e
159169

160170
@when("I send a request to list the phone numbers")
161171
def step_when_list_phone_numbers(context):
162-
sinch_client: SinchClient = context.sinch
163-
response = sinch_client.numbers.active.list(
172+
response = context.sinch.numbers.active.list(
164173
region_code='US',
165174
number_type='LOCAL'
166175
)
167176
# Get the first page
177+
response = execute_sync_or_async(context, response)
168178
context.response = response.content()
169179

170180

@@ -175,15 +185,23 @@ def step_then_response_contains_x_phone_numbers(context, count):
175185

176186
@when("I send a request to list all the phone numbers")
177187
def step_when_list_all_phone_numbers(context):
178-
sinch_client: SinchClient = context.sinch
179-
response = sinch_client.numbers.active.list(
188+
response = context.sinch.numbers.active.list(
180189
region_code='US',
181190
number_type='LOCAL'
182191
)
183192
active_numbers_list = []
184193

185-
for number in response.iterator():
186-
active_numbers_list.append(number)
194+
response = execute_sync_or_async(context, response)
195+
if inspect.isasyncgen(response.iterator()):
196+
async def collect_async_numbers():
197+
async for number in response.iterator():
198+
active_numbers_list.append(number)
199+
200+
execute_sync_or_async(context, collect_async_numbers())
201+
else:
202+
for number in response.iterator():
203+
active_numbers_list.append(number)
204+
187205
context.active_numbers_list = active_numbers_list
188206

189207
@then('the phone numbers list contains "{count}" phone numbers')

0 commit comments

Comments
 (0)