Skip to content

Commit 7bdce8a

Browse files
committed
more and better tests
1 parent 0364003 commit 7bdce8a

File tree

1 file changed

+158
-71
lines changed

1 file changed

+158
-71
lines changed

tests/unit/actor/test_actor_lifecycle.py

Lines changed: 158 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -5,64 +5,185 @@
55
import json
66
import sys
77
from datetime import datetime, timezone
8-
from typing import TYPE_CHECKING, Any, cast
8+
from typing import TYPE_CHECKING, Any
99
from unittest import mock
1010
from unittest.mock import AsyncMock, Mock
1111

1212
import pytest
1313
import websockets.asyncio.server
1414

15-
from apify_shared.consts import ActorEnvVars, ApifyEnvVars
15+
from apify_shared.consts import ActorEnvVars, ActorExitCodes, ApifyEnvVars
1616
from crawlee.events._types import Event, EventPersistStateData
1717

18-
import apify._actor
1918
from apify import Actor
2019

2120
if TYPE_CHECKING:
22-
from collections.abc import Callable
21+
from collections.abc import AsyncGenerator, Callable
22+
23+
from apify._actor import _ActorType
24+
25+
26+
@pytest.fixture(
27+
params=[
28+
pytest.param(('instance', 'manual'), id='instance-manual'),
29+
pytest.param(('instance', 'async_with'), id='instance-async-with'),
30+
pytest.param(('class', 'manual'), id='class-manual'),
31+
pytest.param(('class', 'async_with'), id='class-async-with'),
32+
]
33+
)
34+
async def actor(
35+
request: pytest.FixtureRequest,
36+
) -> AsyncGenerator[_ActorType, None]:
37+
"""Yield Actor instance or class in different initialization modes.
38+
39+
- instance-manual: Actor() with manual init()/exit()
40+
- instance-async-with: Actor() used as async context manager
41+
- class-manual: Actor class with manual init()/exit()
42+
- class-async-with: Actor class used as async context manager
43+
44+
Each Actor is properly initialized before yielding and cleaned up after.
45+
"""
46+
scope, mode = request.param
47+
48+
if scope == 'instance':
49+
if mode == 'manual':
50+
instance = Actor()
51+
await instance.init()
52+
yield instance
53+
await instance.exit()
54+
else:
55+
async with Actor() as instance:
56+
yield instance
57+
58+
elif scope == 'class':
59+
if mode == 'manual':
60+
await Actor.init()
61+
yield Actor
62+
await Actor.exit()
63+
else:
64+
async with Actor:
65+
yield Actor
2366

24-
from lazy_object_proxy import Proxy
67+
else:
68+
raise ValueError(f'Unknown scope: {scope}')
2569

2670

27-
async def test_actor_properly_init_with_async() -> None:
28-
async with Actor:
29-
assert cast('Proxy', apify._actor.Actor).__wrapped__ is not None
30-
assert cast('Proxy', apify._actor.Actor).__wrapped__._is_initialized
31-
assert not cast('Proxy', apify._actor.Actor).__wrapped__._is_initialized
71+
async def test_actor_init_instance_manual() -> None:
72+
"""Test that Actor instance can be properly initialized and cleaned up manually."""
73+
actor = Actor()
74+
await actor.init()
75+
assert actor._is_initialized is True
76+
await actor.exit()
77+
assert actor._is_initialized is False
3278

3379

34-
async def test_actor_init() -> None:
35-
my_actor = Actor()
80+
async def test_actor_init_instance_async_with() -> None:
81+
"""Test that Actor instance can be properly initialized and cleaned up using async context manager."""
82+
actor = Actor()
83+
async with actor:
84+
assert actor._is_initialized is True
3685

37-
await my_actor.init()
38-
assert my_actor._is_initialized is True
86+
assert actor._is_initialized is False
3987

40-
await my_actor.exit()
41-
assert my_actor._is_initialized is False
4288

89+
async def test_actor_init_class_manual() -> None:
90+
"""Test that Actor class can be properly initialized and cleaned up manually."""
91+
await Actor.init()
92+
assert Actor._is_initialized is True
93+
await Actor.exit()
94+
assert not Actor._is_initialized
4395

44-
async def test_double_init_raises_error(prepare_test_env: Callable) -> None:
96+
97+
async def test_actor_init_class_async_with() -> None:
98+
"""Test that Actor class can be properly initialized and cleaned up using async context manager."""
4599
async with Actor:
46-
assert Actor._is_initialized
47-
with pytest.raises(RuntimeError):
48-
await Actor.init()
100+
assert Actor._is_initialized is True
49101

50-
prepare_test_env()
102+
assert not Actor._is_initialized
51103

52-
async with Actor() as actor:
53-
assert actor._is_initialized
54-
with pytest.raises(RuntimeError):
55-
await actor.init()
56104

57-
prepare_test_env()
105+
async def test_fail_properly_deinitializes_actor(actor: _ActorType) -> None:
106+
"""Test that fail() method properly deinitializes the Actor."""
107+
assert actor._is_initialized
108+
await actor.fail()
109+
assert actor._is_initialized is False
58110

59-
async with Actor() as actor:
60-
assert actor._is_initialized
61-
with pytest.raises(RuntimeError):
62-
await actor.init()
63111

112+
async def test_actor_handles_exceptions_and_cleans_up_properly() -> None:
113+
"""Test that Actor properly cleans up when an exception occurs in the async context manager."""
114+
actor = None
115+
116+
with contextlib.suppress(Exception):
117+
async with Actor() as actor:
118+
assert actor._is_initialized
119+
raise Exception('Failed') # noqa: TRY002
120+
121+
assert actor is not None
122+
assert actor._is_initialized is False
123+
124+
125+
async def test_double_init_raises_runtime_error(actor: _ActorType) -> None:
126+
"""Test that attempting to initialize an already initialized Actor raises RuntimeError."""
127+
assert actor._is_initialized
128+
with pytest.raises(RuntimeError):
129+
await actor.init()
130+
131+
132+
async def test_exit_without_init_raises_runtime_error() -> None:
133+
"""Test that calling exit() on an uninitialized Actor raises RuntimeError."""
134+
with pytest.raises(RuntimeError):
135+
await Actor.exit()
136+
137+
with pytest.raises(RuntimeError):
138+
await Actor().exit()
139+
140+
141+
async def test_fail_without_init_raises_runtime_error() -> None:
142+
"""Test that calling fail() on an uninitialized Actor raises RuntimeError."""
143+
with pytest.raises(RuntimeError):
144+
await Actor.fail()
145+
146+
with pytest.raises(RuntimeError):
147+
await Actor().fail()
148+
149+
150+
async def test_reboot_in_local_environment_logs_error_message(
151+
actor: _ActorType,
152+
caplog: pytest.LogCaptureFixture,
153+
) -> None:
154+
"""Test that reboot() logs an error when not running on the Apify platform."""
155+
await actor.reboot()
156+
157+
# Check that the error message was logged
158+
assert 'Actor.reboot() is only supported when running on the Apify platform.' in caplog.text
64159

65-
async def test_actor_exits_cleanly_with_events(monkeypatch: pytest.MonkeyPatch) -> None:
160+
161+
async def test_exit_sets_custom_exit_code_and_status_message(actor: _ActorType) -> None:
162+
"""Test that exit() properly sets custom exit code and status message."""
163+
await actor.exit(exit_code=42, status_message='Exiting with code 42')
164+
assert actor.exit_code == 42
165+
assert actor.status_message == 'Exiting with code 42'
166+
167+
168+
async def test_fail_sets_custom_exit_code_and_status_message(actor: _ActorType) -> None:
169+
"""Test that fail() properly sets custom exit code and status message."""
170+
await actor.fail(exit_code=99, status_message='Failing with code 99')
171+
assert actor.exit_code == 99
172+
assert actor.status_message == 'Failing with code 99'
173+
174+
175+
async def test_unhandled_exception_sets_error_exit_code() -> None:
176+
"""Test that unhandled exceptions in context manager set the error exit code."""
177+
actor = Actor(exit_process=False)
178+
with pytest.raises(RuntimeError):
179+
async with actor:
180+
raise RuntimeError('Test error')
181+
182+
assert actor.exit_code == ActorExitCodes.ERROR_USER_FUNCTION_THREW.value
183+
184+
185+
async def test_actor_stops_periodic_events_after_exit(monkeypatch: pytest.MonkeyPatch) -> None:
186+
"""Test that periodic events (PERSIST_STATE and SYSTEM_INFO) stop emitting after Actor exits."""
66187
monkeypatch.setenv(ApifyEnvVars.SYSTEM_INFO_INTERVAL_MILLIS, '100')
67188
monkeypatch.setenv(ApifyEnvVars.PERSIST_STATE_INTERVAL_MILLIS, '100')
68189
on_persist = []
@@ -77,11 +198,11 @@ def on_event(event_type: Event) -> Callable:
77198
return lambda data: on_system_info.append(data)
78199
return lambda data: print(data)
79200

80-
my_actor = Actor()
81-
async with my_actor:
82-
assert my_actor._is_initialized
83-
my_actor.on(Event.PERSIST_STATE, on_event(Event.PERSIST_STATE))
84-
my_actor.on(Event.SYSTEM_INFO, on_event(Event.SYSTEM_INFO))
201+
actor = Actor()
202+
async with actor:
203+
assert actor._is_initialized
204+
actor.on(Event.PERSIST_STATE, on_event(Event.PERSIST_STATE))
205+
actor.on(Event.SYSTEM_INFO, on_event(Event.SYSTEM_INFO))
85206
await asyncio.sleep(1)
86207

87208
on_persist_count = len(on_persist)
@@ -95,43 +216,9 @@ def on_event(event_type: Event) -> Callable:
95216
assert on_system_info_count == len(on_system_info)
96217

97218

98-
async def test_exit_without_init_raises_error() -> None:
99-
with pytest.raises(RuntimeError):
100-
await Actor.exit()
101-
102-
103-
async def test_actor_fails_cleanly() -> None:
104-
async with Actor() as actor:
105-
assert actor._is_initialized
106-
await actor.fail()
107-
108-
assert actor._is_initialized is False
109-
110-
111-
async def test_actor_handles_failure_gracefully() -> None:
112-
my_actor = None
113-
114-
with contextlib.suppress(Exception):
115-
async with Actor() as my_actor:
116-
assert my_actor._is_initialized
117-
raise Exception('Failed') # noqa: TRY002
118-
119-
assert my_actor is not None
120-
assert my_actor._is_initialized is False
121-
122-
123-
async def test_fail_without_init_raises_error() -> None:
124-
with pytest.raises(RuntimeError):
125-
await Actor.fail()
126-
127-
128-
async def test_actor_reboot_fails_locally() -> None:
129-
with pytest.raises(RuntimeError):
130-
await Actor.reboot()
131-
132-
133219
@pytest.mark.skipif(sys.version_info >= (3, 13), reason='Suffers flaky behavior on Python 3.13')
134220
async def test_actor_handles_migrating_event_correctly(monkeypatch: pytest.MonkeyPatch) -> None:
221+
"""Test that Actor handles MIGRATING events correctly by emitting PERSIST_STATE."""
135222
# This should test whether when you get a MIGRATING event,
136223
# the Actor automatically emits the PERSIST_STATE event with data `{'isMigrating': True}`
137224
monkeypatch.setenv(ApifyEnvVars.PERSIST_STATE_INTERVAL_MILLIS, '500')

0 commit comments

Comments
 (0)